Browse Source

提交材料

liurongli 5 days ago
parent
commit
4ffbc83eab

+ 59 - 0
components/popupPreviewFile.vue

@@ -0,0 +1,59 @@
+<template>
+	<uni-popup ref="popupDialog" :is-mask-click="isMaskClick">
+		<uni-icons @click="CloseDialog" class="colse" type="closeempty" size="24"></uni-icons>
+		<image class="img_width" @click="CloseDialog" :src="filePath" mode="aspectFit"></image>
+	</uni-popup>	
+</template>
+
+<script setup>
+	import uniPopup from '@/uni_modules/uni-popup/components/uni-popup/uni-popup.vue';
+	import {
+		ref
+	} from 'vue';
+	const props = defineProps({
+		isMaskClick:{//蒙版点击是否关闭弹窗
+			type: Boolean,
+			default: true
+		},
+		filePath:{//文件路径
+			type: String,
+			default: ''
+		}
+	})
+	const popupDialog = ref(null);
+	//打开弹窗
+	const OpenDialog = () => {
+		popupDialog.value?.open();
+	}
+	//关闭弹框
+	const CloseDialog = () => {
+		popupDialog.value?.close();
+	}
+	defineExpose({
+		OpenDialog
+	})
+</script>
+
+<style scoped lang="scss">
+	:deep(.maskClass){
+		background-color: rgba(0, 0, 0, 0.8) !important;
+	}
+	.img_width{
+		width: 750rpx;
+		height: 80vh;
+	}
+	.colse{
+		position: fixed;
+		box-sizing: border-box;
+		top: 0px;
+		right: 0px;
+		width: 60px;
+		height: 44px;
+		padding: 6px;
+		line-height: 32px;
+		font-size: 26px;
+		color: #fff !important;
+		text-align: center;
+		z-index:99;
+	}
+</style>

+ 2 - 2
manifest.json

@@ -2,8 +2,8 @@
     "name" : "贯通慧教研",
     "appid" : "__UNI__07352BD",
     "description" : "",
-    "versionName" : "1.2.0",
-    "versionCode" : 20260602,
+    "versionName" : "1.4.0",
+    "versionCode" : 20260812,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {

+ 168 - 35
pages/materialCollection/submitMaterial/submitDetail.vue

@@ -65,37 +65,44 @@
 					
 				</view>
 				<!-- 上传按钮 提交状态 1待提交 2审核中 3已驳回 4已提交 -->
-				<view class="upload_box">
-					<template v-if="state?.targetFileList?.targetStatus === 1 && state?.details?.taskStatus !== 2">
-						<image class="upload_icon" src="@/static/image/icon/upload_icon.png"></image>
-						<view class="upload_text">
-							<view class="upload_title">点击上传文件</view>
-							<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
-						</view>
-					</template>
-					<template v-if="(state?.targetFileList?.targetStatus === 2 || state?.targetFileList?.targetStatus === 4) && state?.details?.taskStatus !== 2">
-						<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
-						<view class="upload_text">
-							<view class="upload_title">点击重新编辑可再提交</view>
-							<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
-						</view>
-					</template>
-					<template v-if="state?.targetFileList?.targetStatus === 3 && state?.details?.taskStatus !== 2">
-						<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
-						<view class="upload_text">
-							<view class="upload_title">请先删除全部文件,再提交调整后的合规材料</view>
-							<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
-						</view>
-					</template>
-					<template v-if="state?.details?.taskStatus == 2">
-						<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
-						<view class="upload_text">
-							<view class="upload_title">已结束不可再提交</view>
-							<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
-						</view>
-					</template>
+				<!-- maxSize:单文件上限 MB;0 表示不限制 maxCount:列表最多文件数 -->
+				<kex-file-uploader 
+				ref="fileUploader" 
+				v-model="files" 
+				mode="headless" 
+				:max-count="20" 
+				:maxSize="100" 
+				formats="doc,docx,xls,xlsx,ppt,pptx,png,jpg,jpeg,gif,bmp">
+				</kex-file-uploader>
+				<view class="upload_box" @click="FileChooseUploader" v-if="state?.targetFileList?.targetStatus === 1 && state?.details?.taskStatus !== 2">
+					<image class="upload_icon" src="@/static/image/icon/upload_icon.png"></image>
+					<view class="upload_text">
+						<view class="upload_title">点击上传文件</view>
+						<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
+					</view>
+				</view>
+				<view class="upload_box" v-if="(state?.targetFileList?.targetStatus === 2 || state?.targetFileList?.targetStatus === 4) && state?.details?.taskStatus !== 2">
+					<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
+					<view class="upload_text">
+						<view class="upload_title">点击重新编辑可再提交</view>
+						<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
+					</view>
 				</view>
-				<view class="list_item" v-for="item in (state?.targetFileList?.taskTargetFileVos || [])" :Key="item.id">
+				<view class="upload_box" v-if="state?.targetFileList?.targetStatus === 3 && state?.details?.taskStatus !== 2">
+					<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
+					<view class="upload_text">
+						<view class="upload_title">请先删除全部文件,再提交调整后的合规材料</view>
+						<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
+					</view>
+				</view>
+				<view class="upload_box" v-if="state?.details?.taskStatus == 2">
+					<image class="upload_icon" src="@/static/image/icon/upload_locked.png"></image>
+					<view class="upload_text">
+						<view class="upload_title">已结束不可再提交</view>
+						<view class="upload_desc">支持Word、Excel、PPT、图片(最大100MB)</view>
+					</view>
+				</view>
+				<view class="list_item" @click="PreviewFile(item)" v-for="item in (state?.targetFileList?.taskTargetFileVos || [])" :Key="item.id">
 					<image class="file_icon" src="@/static/image/icon/file.png"></image>
 					<view class="file_info">
 						<view class="file_name">{{item.fileName}}</view>
@@ -105,8 +112,8 @@
 						</view>
 					</view>
 					<view class="file_btn">
-						<text @click="HandleDeleteFileList(item)" :class="['del',{'disabled':state?.targetFileList?.targetStatus === 2 || state?.targetFileList?.targetStatus === 4}]">删除</text>
-						<text class="download">下载</text>
+						<text @click.stop="HandleDeleteFileList(item)" :class="['del',{'disabled':state?.targetFileList?.targetStatus === 2 || state?.targetFileList?.targetStatus === 4}]">删除</text>
+						<text class="download" @click.stop="ToDownloadFile(item)">下载</text>
 					</view>
 				</view>
 			</view>
@@ -115,7 +122,7 @@
 			<!-- 提交状态 1待提交 2审核中 3已驳回 4已提交 -->
 			<template v-if="(state?.targetFileList?.targetStatus === 1 || state?.targetFileList?.targetStatus === 3)">
 				<view @click="DeleteAllFiles" :class="['del',{'disabled':!state?.targetFileList?.taskTargetFileVos?.length}]">删除全部</view>
-				<view class="submit" @click="UpdateTaskTarget">提交</view>
+				<view :class="['submit',{'disabled':state?.targetFileList?.targetStatus === 3}]" @click="UpdateTaskTarget">提交</view>
 			</template>
 			<template v-else>
 				<view class="del disabled">删除全部</view>
@@ -124,16 +131,23 @@
 		</view>
 		<!-- 删除确认框 -->
 		<popupDialog ref="popupDelDialogRef" :showFootBorder="true" :content="state.delDialog.content" @DialogConfirm="DialogDelConfirm" />
+		<!-- 提示框 -->
+		<popupPreviewFile ref="popupDialogRef" :filePath="state.previewPopup.filePath"></popupPreviewFile>
+		<!-- 文件、图片下载 -->
+		<ss-download ref="ssdownload"></ss-download>
 	</view>
 </template>
 
 <script setup>
 	import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue';
+	import ssDownload from '@/uni_modules/ss-download/components/ss-download/ss-download.vue';
 	import popupDialog from '@/components/popupDialog.vue';
+	import popupPreviewFile from '@/components/popupPreviewFile.vue';
 	import navBar from '@/components/navBar.vue';
 	import { remainingDays,getFileSize } from '@/common/common.js';
-	import {getTaskTargetFileList,DeleteFileList,DelTaskTargetFileList,updateTaskTarget} from '@/reqApi/notification.js';
+	import {getTaskTargetFileList,DeleteFileList,DelTaskTargetFileList,updateTaskTarget,uploadTaskTargetFileList} from '@/reqApi/notification.js';
 	import {onLoad} from '@dcloudio/uni-app';
+	import config from '@/config.js'; // 引入配置文件
 	import {
 		reactive,
 		ref,
@@ -154,8 +168,17 @@
 			fileUrls:[],
 			targetFileIds:[]
 		},
+		previewPopup:{
+			filePath:''
+		}
 	});
 	const popupDelDialogRef = ref(null);
+	const popupDialogRef = ref(null);
+	const fileUploader = ref(null);
+	const ssdownload = ref(null);
+	const files = ref([]);
+	const token = ref(uni.getStorageSync('token'));
+	const apiBaseUrl = ref('');
 	const statusClassMap = {
 		1: 'status_published',
 		2: 'status_closed'
@@ -170,9 +193,114 @@
 	})
 	onMounted(() => {
 		state.details = uni.getStorageSync('submitMaterialDetailInfo') || {};
+		getApiBaseUrl();//上传接口地址
 		checkTextOverflow();
 		GetTaskTargetFileList();
 	});
+	//上传接口地址
+	const getApiBaseUrl = () => {
+		if (process.env.NODE_ENV === 'development') {//本地开发环境
+		  const SYSTEM_INFO = uni.getSystemInfoSync();//获取系统信息的同步接口
+		  apiBaseUrl.value = SYSTEM_INFO.uniPlatform === 'web' ? config.proxyBaseUrl:config.apiBaseUrl;//读取配置文件的apiBaseUrl变量
+		} else {//生产/测试环境
+		  apiBaseUrl.value = config.apiBaseUrl
+		}
+	}
+	//选择文件上传
+	const FileChooseUploader = () => {
+		fileUploader.value.chooseFile({//选择上传文件
+			count: 1,
+			success: (files) => {
+				console.log(files[0])
+				fileUploader.value.upload({//上传单个文件
+					url:`${apiBaseUrl.value}/api/v1/teach/oss/file/oss/upload_file_list`,
+					file: files[0],
+					name: 'files',
+					header: {Authorization: token.value},
+					formData: {
+						taskId:state.taskId,
+						targetId:state.targetId
+					},
+					method: 'POST',
+					withCredentials: true, // iOS 本地测远程且要带 cookie
+					success: (res) => {
+						const result = JSON.parse(res.result);
+						const fileInfoList = result.data.map(item => ({
+							...item,
+							filePath: item.fileUrl
+						}))
+						UploadTargetFileList(fileInfoList);
+					}
+				});
+			}
+		})
+	}
+	//具体采集任务保存上传的文件地址
+	const UploadTargetFileList = (fileInfoList) => {
+		uploadTaskTargetFileList({
+			fileInfoList,
+			taskId:state.taskId,
+			targetId:state.targetId
+		}).then(res=>{
+			if (res.code === 200) {
+				uni.showToast({
+					title: '上传成功!',
+					icon: 'none'
+				});
+				GetTaskTargetFileList();
+			}
+		})
+	}
+	//预览
+	const PreviewFile = (item) => {
+		console.log(item)
+		const fileType = item.fileType;
+		if(IsImageExts(fileType)){//图片预览
+			state.previewPopup.filePath = item.filePath;
+			popupDialogRef.value.OpenDialog();
+		}else{//文件预览
+			// 1. 先下载文件到本地临时目录
+			uni.downloadFile({
+				url: item.filePath,
+				success: (downloadRes) => {
+					if (downloadRes.statusCode === 200) {
+						// 2. 下载成功后,得到临时文件路径 tempFilePath
+						const localPath = downloadRes.tempFilePath;
+						// 3. 构造 file 对象(必须有 path)
+						const fileObj = {
+							name: item.fileName,
+							path: localPath,  // 关键字段
+							size: item.fileSize // 可选
+						};
+						// 4. 调用 kex-file-uploader 的 openDocument 方法预览
+						fileUploader.value?.openDocument({
+							file: fileObj,
+							success: () => console.log('预览成功'),
+							fail: (err) => console.error('预览失败', err)
+						});
+					}
+				},
+				fail: (err) => {
+					uni.showToast({ title: '文件下载失败', icon: 'none' });
+				}
+			});
+		}
+	}
+	//判断是否是图片
+	const IsImageExts = (fileType) => {
+		const type = fileType?.toLowerCase();
+		const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp','svg', 'tiff', 'tif', 'ico', 'avif', 'heic', 'heif'];
+		return imageExts.includes(type)
+	}
+	//下载
+	const ToDownloadFile = (item) => {
+		const fileType = IsImageExts(item.fileType)?'1':'2';//1:图片 2 文档 3 视频
+		ssdownload?.value?.toDownload({
+			type:fileType,
+			src:item.filePath,
+			fileName:item.fileName
+		});
+	}
 	const checkTextOverflow = () => {
 		nextTick(() => {
 			const instance = getCurrentInstance();
@@ -341,7 +469,9 @@
 		display: flex;
 		flex-direction: column;
 		width: 100%;
-		height: calc(100% - 226rpx);
+		// height: calc(100% - 326rpx);
+		flex:1;
+		overflow: auto;
 	}
 	.page_content {
 		&.padding_btm {
@@ -660,6 +790,9 @@
 			font-weight: 500;
 			font-size: 28rpx;
 			color: #FFFFFF;
+			&.disabled{
+				background-color: #C0C4CC;
+			}
 		}
 	}
 </style>

+ 1 - 1
pages/teacherHonor/honorOverview/index.vue

@@ -267,7 +267,7 @@
 		display: flex;
 		flex-wrap: wrap;
 		gap: 22rpx;
-		padding-top: 22rpx;
+		padding: 22rpx 0;
 		.list_item{
 			display: flex;
 			flex-direction: column;

+ 1 - 1
pages/teacherHonor/myHonor/index.vue

@@ -40,7 +40,7 @@
 							<view class="honor_type_item">{{item.honorTypeName}}</view>
 						</view>
 						<!-- reviewStatus: 0 需审核  1 无审核 honorStatus:0 待审核  1 已完成  2 已驳回 -->
-						<view :class="['del',{'disable':Number(item.reviewStatus) === 0 && Number(item.honorStatus) === 1}]" @click="DeleteMyHonor(item)">删除</view>
+						<view :class="['del',{'disable':Number(item.reviewStatus) === 0 && Number(item.honorStatus) === 1}]" @click.stop="DeleteMyHonor(item)">删除</view>
 					</view>
 					<view class="honor_content">
 						<image class="honor_pic" :src="item.certPicUrl"></image>

+ 10 - 3
pages/teacherHonor/myHonor/uploadEditMyHonor.vue

@@ -55,6 +55,7 @@
 	import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue';
 	import {queryHonorTypeDictList,queryHonorDataDetailById,addHonorCertData,updateHonorCertData} from '@/reqApi/teacherHonor.js';
 	import { unioformDateTransform,fileSizeConvert } from '@/common/common.js';
+	import config from '@/config.js'; // 引入配置文件
 	import {
 		reactive,
 		ref,
@@ -288,7 +289,7 @@
 			state.formData.certPicDatas.push({
 				...item,
 				status: 'uploading',
-				message: '上传中'
+				message: '上传中1'
 			})
 		})
 		for (let i = 0; i < lists.length; i++) {
@@ -311,8 +312,15 @@
 	const uploadFilePromise = (url) => {
 		return new Promise((resolve, reject) => {
 			const token = uni.getStorageSync('token'); // 假设从本地存储获取token
+			let apiBaseUrl = '';
+			if (process.env.NODE_ENV === 'development') {//本地开发环境
+			  const SYSTEM_INFO = uni.getSystemInfoSync();//获取系统信息的同步接口
+			  apiBaseUrl = SYSTEM_INFO.uniPlatform === 'web' ? config.proxyBaseUrl:config.apiBaseUrl;//读取配置文件的apiBaseUrl变量
+			} else {//生产/测试环境
+			  apiBaseUrl = config.apiBaseUrl
+			}
 			let a = uni.uploadFile({
-				url: '/api/v1/teach/oss/file/oss/upload_filesSele',
+				url: `${apiBaseUrl}/api/v1/teach/oss/file/oss/upload_filesSele`,
 				filePath: url,
 				name: 'file',
 				header:{
@@ -320,7 +328,6 @@
 				},
 				success: (res) => {
 					const resdata = JSON.parse(res.data);
-					console.log(res,1323)
 					resolve(resdata)
 				}
 			});

+ 4 - 0
reqApi/notification.js

@@ -75,4 +75,8 @@ export const DelTaskTargetFileList = (data) => {
 // 修改教师任务提交状态为待提交
 export const updateTaskTarget = (taskTargetId) => {
   return request.post(`/api/v1/teaching_plan/collect/task_target/update_task_target?taskTargetId=${taskTargetId}`)
+}
+// 具体采集任务保存上传的文件地址
+export const uploadTaskTargetFileList = (data) => {
+  return request.post('/api/v1/teaching_plan/collect/task_target_file/upload_task_target_file_list',data)
 }

+ 32 - 0
uni_modules/kex-file-uploader/changelog.md

@@ -0,0 +1,32 @@
+## 1.2.0(2026-07-16)
+- 支持鸿蒙:`uni.chooseFile` + `uni.uploadFile`(非 renderjs);`package.json` 标记 harmony √
+- 新增:`debug` / `distinct`、`uploadOptions.method` / `toBase`、JSON body、`withCredentials`
+- 新增:`chooseFile`、命令式 `upload({url,file,...})`、`openDocument`、`getTempFilePath` / `getBase` / `getArrayBuffer`
+- 文档:多端表拆分 Android/iOS vs 鸿蒙
+## 1.1.5(2026-07-16)
+- 新增:`uploadOptions.withCredentials`(App XHR 跨域携带 cookie);文档补充 CORS 说明
+
+## 1.1.4(2026-07-16)
+- 文档:readme 按「照着就能用」补全——三种用法对比、完整示例、事件载荷、FAQ、提交字段说明等
+
+## 1.1.3(2026-07-16)
+- 明确多选:新增 `multiple`(默认 true);App 每次重建 file input 并设置 multiple,修复部分安卓真机无法多选
+
+## 1.1.2(2026-07-16)
+- 修复:App 真机选图后无法预览;dataURL/blob 改用组件内全屏预览,原图经 renderjs `createObjectURL` 加载
+
+## 1.1.1(2026-07-16)
+- 修复:App 真机选图无本地 path 导致只显示「图」字;renderjs 生成缩略图 dataURL(`thumb`)供列表展示
+
+## 1.1.0(2026-07-16)
+- 新增:`readonly`、`beforeRemove`(确认删除)
+- 新增:`responseUrlKey`、`parseResponse` 响应解析
+- 新增:`#item` 插槽、图片缩略图(`showThumb`)
+- 新增:`abort` / `abortAll`、并发上传 `concurrent`(默认 3)
+- 新增:`mode="headless"` 纯能力模式
+
+## 1.0.1(2026-07-16)
+- 触发按钮可自定义:`#trigger` / `#add` / `addStyle` / `showTrigger` / `triggerPosition`
+
+## 1.0.0(2026-07-16)
+- 首发:附件上传、列表 UI、多端选文件、uploadUrl + uploadOptions

+ 2449 - 0
uni_modules/kex-file-uploader/components/kex-file-uploader/kex-file-uploader.vue

@@ -0,0 +1,2449 @@
+<template>
+	<view
+		class="kex-fu"
+		:class="{
+			'kex-fu--disabled': disabled || readonly,
+			'kex-fu--readonly': readonly,
+			'kex-fu--headless': isHeadless
+		}"
+	>
+		<!-- list 模式:完整 UI -->
+		<template v-if="!isHeadless">
+			<!-- 触发按钮:顶部 -->
+			<template v-if="triggerVisible && triggerPosition === 'top'">
+				<slot
+					v-if="hasTriggerSlot"
+					name="trigger"
+					v-bind="triggerSlotProps"
+				/>
+				<view
+					v-else
+					class="kex-fu__add"
+					:class="addClass"
+					:style="addStyle"
+					@click="onChoose"
+				>
+					<slot name="add" :count="innerList.length" :max-count="maxCount">
+						<text class="kex-fu__add-icon">+</text>
+						<text class="kex-fu__add-text">{{ addText }}</text>
+						<text v-if="showCount" class="kex-fu__add-count">{{ innerList.length }}/{{ maxCount }}</text>
+					</slot>
+				</view>
+			</template>
+
+			<!-- 文件列表 -->
+			<view
+				v-for="(item, index) in innerList"
+				:key="item.uid"
+				class="kex-fu__item"
+			>
+				<slot
+					name="item"
+					:file="toPublicFile(item)"
+					:index="index"
+					:remove="() => onRemove(index)"
+					:retry="() => onRetry(index)"
+					:abort="() => abort(index)"
+					:preview="() => onPreview(item)"
+				>
+					<!-- 图片缩略图 / 类型图标 -->
+					<image
+						v-if="isImageItem(item) && thumbSrc(item)"
+						class="kex-fu__thumb"
+						:src="thumbSrc(item)"
+						mode="aspectFill"
+						@click="onPreview(item)"
+					/>
+					<view
+						v-else
+						class="kex-fu__icon"
+						:class="'kex-fu__icon--' + iconType(item)"
+						@click="onPreview(item)"
+					>
+						<text class="kex-fu__icon-text">{{ iconLabel(item) }}</text>
+					</view>
+
+					<view class="kex-fu__body" @click="onPreview(item)">
+						<text class="kex-fu__name">{{ item.name }}</text>
+						<text class="kex-fu__meta">
+							{{ sizeText(item.size) }}
+							<text v-if="item.status === 'uploading'"> · {{ item.progress || 0 }}%</text>
+							<text v-else-if="item.status === 'fail'" class="kex-fu__fail"> · 上传失败</text>
+							<text v-else-if="item.status === 'success'" class="kex-fu__ok"> · 已上传</text>
+						</text>
+						<view v-if="item.status === 'uploading'" class="kex-fu__bar">
+							<view class="kex-fu__bar-inner" :style="{ width: (item.progress || 0) + '%' }" />
+						</view>
+					</view>
+
+					<view class="kex-fu__actions">
+						<text
+							v-if="item.status === 'uploading' && !readonly"
+							class="kex-fu__abort"
+							@click.stop="abort(index)"
+						>取消</text>
+						<text
+							v-if="item.status === 'fail' && uploadUrl && !readonly && !disabled"
+							class="kex-fu__retry"
+							@click.stop="onRetry(index)"
+						>重试</text>
+						<text
+							v-if="canRemoveItem(item)"
+							class="kex-fu__remove"
+							@click.stop="onRemove(index)"
+						>×</text>
+					</view>
+				</slot>
+			</view>
+
+			<!-- 触发按钮:底部 -->
+			<template v-if="triggerVisible && triggerPosition === 'bottom'">
+				<slot
+					v-if="hasTriggerSlot"
+					name="trigger"
+					v-bind="triggerSlotProps"
+				/>
+				<view
+					v-else
+					class="kex-fu__add"
+					:class="addClass"
+					:style="addStyle"
+					@click="onChoose"
+				>
+					<slot name="add" :count="innerList.length" :max-count="maxCount">
+						<text class="kex-fu__add-icon">+</text>
+						<text class="kex-fu__add-text">{{ addText }}</text>
+						<text v-if="showCount" class="kex-fu__add-count">{{ innerList.length }}/{{ maxCount }}</text>
+					</slot>
+				</view>
+			</template>
+		</template>
+
+		<!-- 图片内置预览(dataURL / blob,App 真机 uni.previewImage 不可用) -->
+		<view
+			v-if="previewVisible"
+			class="kex-fu__preview"
+			@click="closePreview"
+			@touchmove.stop.prevent="noop"
+		>
+			<image
+				class="kex-fu__preview-img"
+				:src="previewSrc"
+				mode="aspectFit"
+				@click.stop="noop"
+				@error="onPreviewImgError"
+			/>
+			<text class="kex-fu__preview-close">点击关闭</text>
+		</view>
+
+		<!-- App renderjs(list / headless 都需要) -->
+		<!-- #ifdef APP-PLUS -->
+		<view
+			id="kex-fu-render"
+			ref="renderHost"
+			class="kex-fu__render"
+			:change:appChoose="render.chooseHandle"
+			:appChoose="appChooseToken"
+			:change:appUpload="render.uploadHandle"
+			:appUpload="appUploadToken"
+			:change:appRemove="render.removeHandle"
+			:appRemove="appRemoveToken"
+			:change:appAbort="render.abortHandle"
+			:appAbort="appAbortToken"
+			:change:appPreview="render.previewHandle"
+			:appPreview="appPreviewToken"
+			:change:appRead="render.readHandle"
+			:appRead="appReadToken"
+		/>
+		<!-- #endif -->
+	</view>
+</template>
+
+<script>
+	/**
+	 * kex-file-uploader:附件上传
+	 * list / headless · 多端含鸿蒙 · 并发 · abort · 缩略图
+	 *
+	 * 平台说明:
+	 * - Android/iOS App(APP-PLUS):renderjs <input> + XHR
+	 * - 鸿蒙 App(APP-HARMONY,非 APP-PLUS):uni.chooseFile + uni.uploadFile
+	 * - H5 / 小程序:uni.chooseFile(或各端专用 API)+ uni.uploadFile
+	 */
+	const DEFAULT_UPLOAD_OPTIONS = Object.freeze({
+		name: 'file',
+		header: {},
+		formData: {},
+		/** App XHR 请求方法,默认 POST */
+		method: 'POST',
+		/** App:true 时以 Base64 字符串作为表单字段上传 */
+		toBase: false,
+		/** App XHR / 需带 cookie 跨域时设为 true */
+		withCredentials: false
+	})
+
+	let uidSeed = 0
+	const nextUid = () => {
+		uidSeed += 1
+		return 'kex-fu-' + uidSeed + '-' + Date.now()
+	}
+
+	const IMAGE_EXTS = new Set([
+		'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'heic', 'heif'
+	])
+	const DOC_EXTS = new Set([
+		'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'md', 'rtf'
+	])
+	const VIDEO_EXTS = new Set([
+		'mp4', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'webm', 'm4v'
+	])
+	const AUDIO_EXTS = new Set([
+		'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'
+	])
+
+	function getExt(name = '') {
+		const i = String(name).lastIndexOf('.')
+		return i >= 0 ? String(name).substring(i + 1).toLowerCase() : ''
+	}
+
+	function inferName(path) {
+		if (!path) return 'file_' + Date.now()
+		const base = String(path).substring(String(path).lastIndexOf('/') + 1)
+		return base || 'file_' + Date.now()
+	}
+
+	function formatBytes(n) {
+		const num = Number(n) || 0
+		if (num <= 0) return ''
+		if (num < 1024) return num + 'B'
+		if (num < 1024 * 1024) return (num / 1024).toFixed(num < 10 * 1024 ? 1 : 0) + 'K'
+		return (num / 1024 / 1024).toFixed(2) + 'M'
+	}
+
+	function parseFormats(formats) {
+		if (!formats) return []
+		if (Array.isArray(formats)) {
+			return formats.map((s) => String(s).replace(/^\./, '').trim().toLowerCase()).filter(Boolean)
+		}
+		return String(formats)
+			.replace(/[\[\]]/g, '')
+			.split(',')
+			.map((s) => s.replace(/^\./, '').trim().toLowerCase())
+			.filter(Boolean)
+	}
+
+	function getByPath(obj, path) {
+		if (!path || obj == null) return ''
+		const parts = String(path).split('.').filter(Boolean)
+		let cur = obj
+		for (let i = 0; i < parts.length; i++) {
+			if (cur == null) return ''
+			cur = cur[parts[i]]
+		}
+		if (cur == null || cur === '') return ''
+		return typeof cur === 'string' || typeof cur === 'number' ? String(cur) : ''
+	}
+
+	function parseResponseData(resData) {
+		if (resData == null || resData === '') return null
+		if (typeof resData === 'object') return resData
+		try {
+			return JSON.parse(resData)
+		} catch (e) {
+			return resData
+		}
+	}
+
+	function defaultParseUploadUrl(resData) {
+		if (!resData) return ''
+		try {
+			const data = typeof resData === 'string' ? JSON.parse(resData) : resData
+			if (typeof data === 'string') return data
+			return (
+				data.url ||
+				data.path ||
+				(data.data && (data.data.url || data.data.path || data.data.fileUrl)) ||
+				(data.result && (data.result.url || data.result.path)) ||
+				''
+			)
+		} catch (e) {
+			return ''
+		}
+	}
+
+	export default {
+		name: 'KexFileUploader',
+		emits: [
+			'update:modelValue',
+			'change',
+			'select',
+			'oversize',
+			'exceed',
+			'remove',
+			'uploadSuccess',
+			'uploadFail',
+			'uploadAbort',
+			'preview'
+		],
+		props: {
+			modelValue: {
+				type: Array,
+				default() {
+					return []
+				}
+			},
+			/**
+			 * list:列表 UI(默认)
+			 * headless:无 UI,仅能力(choose / upload / abort),完全自绘
+			 */
+			mode: {
+				type: String,
+				default: 'list'
+			},
+			maxCount: { type: Number, default: 9 },
+			maxSize: { type: Number, default: 20 },
+			/**
+			 * 是否允许多选(单次选择多个文件)
+			 * false 时每次只能选 1 个,但仍可多次点选直到 maxCount
+			 */
+			multiple: { type: Boolean, default: true },
+			formats: {
+				type: [String, Array],
+				default: ''
+			},
+			fileType: {
+				type: String,
+				default: 'all'
+			},
+			accept: {
+				type: String,
+				default: ''
+			},
+			disabled: { type: Boolean, default: false },
+			/** 只读:不可选、不可删、不可上传操作,仅展示 */
+			readonly: { type: Boolean, default: false },
+			showRemove: { type: Boolean, default: true },
+			showCount: { type: Boolean, default: true },
+			/** 图片是否显示缩略图 */
+			showThumb: { type: Boolean, default: true },
+			addText: { type: String, default: '添加附件' },
+			showTrigger: { type: Boolean, default: true },
+			triggerPosition: {
+				type: String,
+				default: 'bottom'
+			},
+			addClass: {
+				type: [String, Array, Object],
+				default: ''
+			},
+			addStyle: {
+				type: [Object, String],
+				default() {
+					return {}
+				}
+			},
+			toast: { type: Boolean, default: true },
+			autoUpload: { type: Boolean, default: false },
+			uploadUrl: { type: String, default: '' },
+			uploadOptions: {
+				type: Object,
+				default() {
+					return {
+						name: 'file',
+						header: {},
+						formData: {},
+						method: 'POST',
+						toBase: false,
+						withCredentials: false
+					}
+				}
+			},
+			/**
+			 * 并发上传数,默认 3;≤0 视为 1
+			 */
+			concurrent: {
+				type: Number,
+				default: 3
+			},
+			/**
+			 * 从响应中取远程 url 的路径,如 'data.url'、'data.fileUrl'
+			 * 优先于内置默认解析
+			 */
+			responseUrlKey: {
+				type: String,
+				default: ''
+			},
+			/**
+			 * 自定义解析响应:(rawData, res) => string | { url, data? }
+			 * 优先级最高
+			 */
+			parseResponse: {
+				type: Function,
+				default: null
+			},
+			/**
+			 * 删除前钩子:({ index, file }) => boolean | Promise<boolean>
+			 * 返回 false 拦截删除。常用于确认弹窗。
+			 */
+			beforeRemove: {
+				type: Function,
+				default: null
+			},
+			/** 调试日志 */
+			debug: {
+				type: Boolean,
+				default: false
+			},
+			/**
+			 * 同名文件是否覆盖
+			 * false:自动重命名 name(1).ext;true:覆盖同名
+			 */
+			distinct: {
+				type: Boolean,
+				default: false
+			}
+		},
+		data() {
+			return {
+				innerList: [],
+				syncing: false,
+				uploadTasks: {},
+				appChooseToken: null,
+				appUploadToken: null,
+				appRemoveToken: null,
+				appAbortToken: null,
+				appPreviewToken: null,
+				appReadToken: null,
+				appUploadCallbacks: {},
+				/** chooseFile 一次性回调 */
+				chooseFileSuccess: null,
+				chooseFileFail: null,
+				/** 命令式 upload / 读文件回调 Map */
+				uploadCallbackMap: {},
+				readCallbackMap: {},
+				previewVisible: false,
+				previewSrc: '',
+				previewFallback: '',
+				previewBlobUrls: []
+			}
+		},
+		computed: {
+			isHeadless() {
+				return this.mode === 'headless'
+			},
+			canAddMore() {
+				return !this.disabled && !this.readonly && this.innerList.length < this.maxCount
+			},
+			triggerVisible() {
+				if (this.isHeadless) return false
+				return this.showTrigger !== false && this.canAddMore
+			},
+			hasTriggerSlot() {
+				return !!(this.$slots && this.$slots.trigger)
+			},
+			triggerSlotProps() {
+				return {
+					count: this.innerList.length,
+					maxCount: this.maxCount,
+					disabled: this.disabled || this.readonly,
+					choose: this.onChoose,
+					readonly: this.readonly
+				}
+			},
+			formatList() {
+				return parseFormats(this.formats)
+			},
+			resolvedUploadOptions() {
+				const o = this.uploadOptions || {}
+				const method = (o.method || DEFAULT_UPLOAD_OPTIONS.method || 'POST').toString().toUpperCase()
+				return {
+					name: o.name || DEFAULT_UPLOAD_OPTIONS.name,
+					header: o.header && typeof o.header === 'object' ? o.header : {},
+					formData: o.formData && typeof o.formData === 'object' ? o.formData : {},
+					method,
+					toBase: o.toBase === true,
+					withCredentials: o.withCredentials === true
+				}
+			},
+			poolSize() {
+				const n = Number(this.concurrent)
+				if (!n || n < 1) return 1
+				return Math.floor(n)
+			}
+		},
+		watch: {
+			modelValue: {
+				immediate: true,
+				deep: true,
+				handler(val) {
+					if (this.syncing) return
+					this.syncFromModel(val)
+				}
+			}
+		},
+		beforeUnmount() {
+			this.abortAll()
+			this.closePreview()
+		},
+		methods: {
+			noop() {},
+			tip(msg) {
+				if (!this.toast || !msg) return
+				uni.showToast({ title: String(msg), icon: 'none' })
+			},
+			isRemoteOrFileUrl(src) {
+				if (!src || typeof src !== 'string') return false
+				if (/^data:|^blob:/i.test(src)) return false
+				return /^(https?:|file:|\/|_doc\/|_www\/|wxfile:|ttfile:|content:)/i.test(src) || src.indexOf('/') >= 0
+			},
+			openInnerPreview(src, fallback) {
+				if (!src && !fallback) {
+					this.tip('暂无可预览内容')
+					return
+				}
+				this.previewFallback = fallback || ''
+				this.previewSrc = src || fallback
+				this.previewVisible = true
+			},
+			onPreviewImgError() {
+				if (this.previewFallback && this.previewSrc !== this.previewFallback) {
+					this.previewSrc = this.previewFallback
+					return
+				}
+				this.tip('图片预览失败')
+			},
+			closePreview() {
+				this.previewVisible = false
+				this.previewSrc = ''
+				this.previewFallback = ''
+				// #ifdef APP-PLUS
+				if (this.previewBlobUrls.length) {
+					this.appPreviewToken = {
+						t: Date.now(),
+						action: 'revoke',
+						urls: this.previewBlobUrls.slice()
+					}
+					this.previewBlobUrls = []
+				}
+				// #endif
+			},
+			sizeText(n) {
+				return formatBytes(n)
+			},
+			isImageItem(item) {
+				if (!item) return false
+				const ext = item.ext || getExt(item.name)
+				if (IMAGE_EXTS.has(ext)) return true
+				const mime = item.mime || item.type || ''
+				return typeof mime === 'string' && mime.indexOf('image/') === 0
+			},
+			thumbSrc(item) {
+				if (!this.showThumb || !item) return ''
+				// App 选图常无本地 path,用 renderjs 生成的 thumb(dataURL)
+				return item.thumb || item.url || item.path || ''
+			},
+			iconType(item) {
+				const ext = item.ext || getExt(item.name)
+				if (IMAGE_EXTS.has(ext)) return 'image'
+				if (DOC_EXTS.has(ext)) return 'doc'
+				if (VIDEO_EXTS.has(ext)) return 'video'
+				if (AUDIO_EXTS.has(ext)) return 'audio'
+				return 'file'
+			},
+			iconLabel(item) {
+				const map = { image: '图', doc: '文', video: '视', audio: '音', file: '件' }
+				return map[this.iconType(item)] || '件'
+			},
+			canRemoveItem(item) {
+				if (this.readonly || this.disabled || !this.showRemove) return false
+				if (!item) return false
+				return item.status !== 'uploading'
+			},
+			toPublicFile(item) {
+				if (!item) return null
+				return {
+					name: item.name,
+					url: item.url,
+					path: item.path,
+					size: item.size,
+					ext: item.ext,
+					status: item.status,
+					progress: item.progress,
+					uid: item.uid,
+					message: item.message || ''
+				}
+			},
+			normalizeItem(raw, fallback = {}) {
+				const name = raw.name || inferName(raw.url || raw.path) || fallback.name || 'file'
+				const path = raw.path || raw.tempFilePath || ''
+				// url 只保留真实路径/远程地址,不用 thumb(避免 base64 进 v-model)
+				const url = raw.url || path
+				const ext = raw.ext || getExt(name)
+				const hasRemoteOnly = !!(raw.url && !raw.path && !path && !raw.tempFilePath)
+				const thumb = raw.thumb || ''
+				return {
+					uid: raw.uid || nextUid(),
+					name,
+					url,
+					path: path || (hasRemoteOnly ? '' : url),
+					thumb,
+					size: Number(raw.size) || 0,
+					ext,
+					mime: raw.mime || raw.type || '',
+					status: raw.status || (hasRemoteOnly ? 'success' : 'ready'),
+					progress:
+						raw.progress != null
+							? raw.progress
+							: raw.status === 'success' || hasRemoteOnly
+								? 100
+								: 0,
+					message: raw.message || '',
+					appKey: raw.appKey || ''
+				}
+			},
+			toEmitList(list) {
+				return (list || this.innerList).map((i) => this.toPublicFile(i))
+			},
+			emitChange(extra) {
+				const fileList = this.toEmitList()
+				this.syncing = true
+				this.$emit('update:modelValue', fileList)
+				this.$emit('change', { fileList, ...(extra || {}) })
+				this.$nextTick(() => {
+					this.syncing = false
+				})
+			},
+			syncFromModel(val) {
+				const list = Array.isArray(val) ? val : []
+				const old = this.innerList
+				this.innerList = list.map((raw, i) => {
+					const prev =
+						old.find((o) => o.uid === raw.uid) ||
+						old.find((o) => o.url && o.url === raw.url) ||
+						old[i]
+					if (prev && (prev.uid === raw.uid || (prev.url && prev.url === raw.url))) {
+						return this.normalizeItem({
+							...prev,
+							...raw,
+							uid: prev.uid,
+							appKey: prev.appKey,
+							thumb: raw.thumb || prev.thumb
+						})
+					}
+					return this.normalizeItem(raw)
+				})
+			},
+			/** 解析上传成功后的远程 url */
+			resolveRemoteUrl(resData, res) {
+				if (typeof this.parseResponse === 'function') {
+					try {
+						const out = this.parseResponse(resData, res)
+						if (typeof out === 'string') return out
+						if (out && typeof out === 'object') {
+							if (out.url) return String(out.url)
+						}
+					} catch (e) {
+						console.error('[kex-file-uploader] parseResponse error', e)
+					}
+				}
+				const data = parseResponseData(resData)
+				if (this.responseUrlKey) {
+					const byKey = getByPath(data, this.responseUrlKey)
+					if (byKey) return byKey
+					// 若 raw 是字符串再试一次路径(少见)
+					const byRaw = getByPath(
+						typeof resData === 'object' ? resData : { value: resData },
+						this.responseUrlKey
+					)
+					if (byRaw) return byRaw
+				}
+				return defaultParseUploadUrl(resData)
+			},
+			resolveResponsePayload(resData, res) {
+				const data = parseResponseData(resData)
+				if (typeof this.parseResponse === 'function') {
+					try {
+						const out = this.parseResponse(resData, res)
+						if (out && typeof out === 'object' && out.data !== undefined) {
+							return { url: out.url || this.resolveRemoteUrl(resData, res), data: out.data }
+						}
+					} catch (e) {}
+				}
+				return { url: this.resolveRemoteUrl(resData, res), data }
+			},
+			choose() {
+				this.onChoose()
+			},
+			/**
+			 * chooseFile({ success, fail, count, type, formats, size, accept, multiple })
+			 */
+			chooseFile(e = {}) {
+				const { success, fail, ...option } = e || {}
+				this.chooseFileSuccess = typeof success === 'function' ? success : null
+				this.chooseFileFail = typeof fail === 'function' ? fail : null
+				this.onChoose(option)
+			},
+			/**
+			 * upload(index) 按列表下标上传
+			 * upload({ url, file, ... }) 命令式上传
+			 * upload() 无参时等同 uploadAll
+			 */
+			upload(arg) {
+				if (arg == null || arg === '') {
+					return this.uploadAll()
+				}
+				if (typeof arg === 'number') {
+					return this.uploadOne(arg)
+				}
+				if (typeof arg === 'object') {
+					return this.uploadByParams(arg)
+				}
+				return this.uploadOne(arg)
+			},
+			uploadAll() {
+				const indexes = []
+				this.innerList.forEach((it, i) => {
+					if (it.status === 'ready' || it.status === 'fail') indexes.push(i)
+				})
+				return this.runUploadPool(indexes)
+			},
+			clear() {
+				this.abortAll()
+				// #ifdef APP-PLUS
+				this.appRemoveToken = { all: true, t: Date.now() }
+				// #endif
+				this.innerList = []
+				this.emitChange({ cleared: true })
+			},
+			getFileList() {
+				return this.toEmitList()
+			},
+			/**
+			 * openDocument({ file, success, fail })
+			 */
+			openDocument({ file, success, fail } = {}) {
+				if (!file) {
+					const err = new Error('未传入文件')
+					if (typeof fail === 'function') fail(err)
+					else this.tip('未传入文件')
+					return
+				}
+				this.getTempFilePath({
+					file,
+					success: (e) => {
+						const path = e && e.result
+						if (!path) {
+							const err = new Error('无法获取文件路径')
+							if (typeof fail === 'function') fail(err)
+							else this.tip('无法预览该文件')
+							return
+						}
+						// #ifdef H5
+						try {
+							window.open(path, '_blank')
+							if (typeof success === 'function') success({ path })
+						} catch (err) {
+							if (typeof fail === 'function') fail(err)
+						}
+						// #endif
+						// #ifndef H5
+						uni.openDocument({
+							filePath: path,
+							showMenu: true,
+							success: (res) => {
+								if (typeof success === 'function') success(res)
+							},
+							fail: (error) => {
+								if (typeof fail === 'function') fail(error)
+								else this.tip('打开文档失败')
+							}
+						})
+						// #endif
+					},
+					fail: (error) => {
+						if (typeof fail === 'function') fail(error)
+						else this.tip('打开文档失败')
+					}
+				})
+			},
+			/**
+			 * getTempFilePath:有 path 直接返回;App renderjs 无 path 时走读文件写临时路径
+			 */
+			getTempFilePath(params = {}) {
+				const file = params.file
+				if (!file || !file.name) {
+					const err = new Error('ERROR: file is undefined')
+					if (typeof params.fail === 'function') params.fail(err)
+					else throw err
+					return
+				}
+				const path = file.path || file.url || file.tempFilePath
+				if (path && this.isRemoteOrFileUrl(path) && !/^data:|^blob:/i.test(path)) {
+					if (typeof params.success === 'function') {
+						params.success({ name: file.name, result: path, status: 'success' })
+					}
+					return
+				}
+				// #ifdef APP-PLUS
+				const key = file.appKey || file.name
+				this.readCallbackMap[key] = {
+					type: 'getTempFilePath',
+					success: params.success,
+					fail: params.fail,
+					chunks: [],
+					received: new Set(),
+					total: 0,
+					fileType: file.type || file.mime || ''
+				}
+				this.appReadToken = {
+					t: Date.now(),
+					action: 'getTempFilePath',
+					name: key
+				}
+				return
+				// #endif
+				// #ifndef APP-PLUS
+				if (typeof params.fail === 'function') {
+					params.fail(new Error('无可用本地路径'))
+				}
+				// #endif
+			},
+			/** getBase:读取为 Base64 */
+			getBase(params = {}) {
+				this._readFileContent(params, 'getBase')
+			},
+			/** getArrayBuffer:读取为 ArrayBuffer */
+			getArrayBuffer(params = {}) {
+				this._readFileContent(params, 'getArrayBuffer')
+			},
+			_readFileContent(params, kind) {
+				const file = params.file
+				if (!file || !file.name) {
+					const err = new Error('ERROR: file is undefined')
+					if (typeof params.fail === 'function') params.fail(err)
+					else throw err
+					return
+				}
+				const path = file.path || file.url || file.tempFilePath
+				// #ifndef APP-PLUS
+				if (!path) {
+					if (typeof params.fail === 'function') params.fail(new Error('无本地路径'))
+					return
+				}
+				this._wxReadFile(params, kind === 'getBase' ? 'base64' : 'arrayBuffer')
+				return
+				// #endif
+				// #ifdef APP-PLUS
+				const key = file.appKey || file.name
+				this.readCallbackMap[key] = {
+					type: kind,
+					success: params.success,
+					fail: params.fail,
+					chunks: [],
+					received: new Set(),
+					total: 0,
+					fileType: file.type || file.mime || ''
+				}
+				this.appReadToken = {
+					t: Date.now(),
+					action: kind,
+					name: key
+				}
+				// #endif
+			},
+			_wxReadFile(params, encoding) {
+				const file = params.file
+				const path = file.path || file.url
+				const fs = typeof uni.getFileSystemManager === 'function' ? uni.getFileSystemManager() : null
+				if (!fs || typeof fs.readFile !== 'function') {
+					// H5:无文件系统 API 时尝试 fetch
+					// #ifdef H5
+					if (typeof fetch === 'function' && path) {
+						fetch(path)
+							.then((r) => r.blob())
+							.then(
+								(blob) =>
+									new Promise((resolve, reject) => {
+										const reader = new FileReader()
+										reader.onload = () => resolve(reader.result)
+										reader.onerror = reject
+										if (encoding === 'base64') reader.readAsDataURL(blob)
+										else reader.readAsArrayBuffer(blob)
+									})
+							)
+							.then((result) => {
+								let out = result
+								if (encoding === 'base64' && typeof result === 'string') {
+									const i = result.indexOf(',')
+									out = i >= 0 ? result.substring(i + 1) : result
+								}
+								if (typeof params.success === 'function') {
+									params.success({
+										name: file.name,
+										status: 'success',
+										result: out
+									})
+								}
+							})
+							.catch((err) => {
+								if (typeof params.fail === 'function') params.fail(err)
+							})
+						return
+					}
+					// #endif
+					if (typeof params.fail === 'function') {
+						params.fail(new Error('当前平台不支持读文件'))
+					}
+					return
+				}
+				fs.readFile({
+					filePath: path,
+					encoding: encoding === 'base64' ? 'base64' : undefined,
+					success: (res) => {
+						if (typeof params.success === 'function') {
+							params.success({
+								name: file.name,
+								status: 'success',
+								result: res.data
+							})
+						}
+					},
+					fail: (err) => {
+						if (typeof params.fail === 'function') params.fail(err)
+					}
+				})
+			},
+			base64ToArrayBuffer(base64) {
+				const binaryString = typeof atob === 'function' ? atob(base64) : ''
+				const bytes = new Uint8Array(binaryString.length)
+				for (let i = 0; i < binaryString.length; i++) {
+					bytes[i] = binaryString.charCodeAt(i)
+				}
+				return bytes.buffer
+			},
+			base64ToPath(base64, fileName) {
+				return new Promise((resolve, reject) => {
+					// #ifdef APP-PLUS
+					const docDir = plus.io.convertLocalFileSystemURL('_doc/')
+					plus.io.resolveLocalFileSystemURL(
+						docDir,
+						(dirEntry) => {
+							dirEntry.getFile(
+								fileName,
+								{ create: true },
+								(fileEntry) => {
+									fileEntry.createWriter(
+										async (writer) => {
+											const chunkSize = 10 * 1024 * 1024
+											let currentIndex = 0
+											try {
+												const writeChunk = (chunk) =>
+													new Promise((res, rej) => {
+														writer.onerror = (e) => rej(e)
+														writer.onwrite = () => res()
+														writer.writeAsBinary(chunk)
+													})
+												while (currentIndex < base64.length) {
+													const chunk = base64.substring(
+														currentIndex,
+														currentIndex + chunkSize
+													)
+													currentIndex += chunk.length
+													await writeChunk(chunk)
+												}
+												if (this.debug) console.log('[kex-fu] 写入成功', `_doc/${fileName}`)
+												resolve(`_doc/${fileName}`)
+											} catch (e) {
+												reject(e)
+											}
+										},
+										(error) => reject(error)
+									)
+								},
+								(error) => reject(error)
+							)
+						},
+						(error) => reject(error)
+					)
+					// #endif
+					// #ifndef APP-PLUS
+					reject(new Error('该功能仅支持 Android/iOS App(plus.io)'))
+					// #endif
+				})
+			},
+			logDebug(...args) {
+				if (this.debug) console.log('[kex-file-uploader]', ...args)
+			},
+			/** 取消指定下标上传 */
+			abort(index) {
+				const item = this.innerList[index]
+				if (!item || item.status !== 'uploading') return
+				const uid = item.uid
+				const task = this.uploadTasks[uid]
+				if (task && typeof task.abort === 'function') {
+					try {
+						task.abort()
+					} catch (e) {}
+				}
+				// #ifdef APP-PLUS
+				if (item.appKey) {
+					this.appAbortToken = { name: item.appKey, t: Date.now() }
+				}
+				// #endif
+				delete this.uploadTasks[uid]
+				item.status = 'fail'
+				item.message = '已取消'
+				item.progress = 0
+				this.$emit('uploadAbort', { index, file: this.toPublicFile(item) })
+				this.emitChange({ aborted: true })
+			},
+			abortAll() {
+				this.innerList.forEach((it, i) => {
+					if (it.status === 'uploading') this.abort(i)
+				})
+			},
+			async runUploadPool(indexes) {
+				const queue = indexes.slice()
+				if (!queue.length) return []
+				const size = Math.min(this.poolSize, queue.length)
+				const workers = []
+				for (let w = 0; w < size; w++) {
+					workers.push(
+						(async () => {
+							while (queue.length) {
+								const idx = queue.shift()
+								await this.uploadOne(idx)
+							}
+						})()
+					)
+				}
+				return Promise.all(workers)
+			},
+			onChoose(override) {
+				if (this.disabled || this.readonly) {
+					if (this.chooseFileFail) {
+						this.chooseFileFail({ status: 'fail', message: 'disabled' })
+						this.chooseFileSuccess = null
+						this.chooseFileFail = null
+					}
+					return
+				}
+				const remain = this.maxCount - this.innerList.length
+				if (remain <= 0) {
+					this.$emit('exceed', { maxCount: this.maxCount })
+					this.tip('最多上传 ' + this.maxCount + ' 个文件')
+					if (this.chooseFileFail) {
+						this.chooseFileFail({ status: 'fail', message: 'exceed' })
+						this.chooseFileSuccess = null
+						this.chooseFileFail = null
+					}
+					return
+				}
+				const ov = override && typeof override === 'object' ? override : {}
+				const multi =
+					ov.multiple !== undefined ? !!ov.multiple : this.multiple !== false
+				const askCount =
+					ov.count != null ? Math.max(1, Number(ov.count) || 1) : multi ? remain : 1
+				const pickCount = Math.min(askCount, remain, multi ? remain : 1)
+				const formatsRaw =
+					ov.formats !== undefined
+						? ov.formats
+						: this.formatList.join(',')
+				const formatsStr = Array.isArray(formatsRaw)
+					? formatsRaw.join(',')
+					: String(formatsRaw || '')
+				const option = {
+					count: pickCount,
+					size: ov.size != null ? Number(ov.size) : this.maxSize,
+					type: ov.type || this.fileType || 'all',
+					formats: formatsStr,
+					accept: ov.accept != null ? ov.accept : this.accept || '',
+					multiple: multi && pickCount > 1,
+					distinct: this.distinct
+				}
+				this.logDebug('choose', option)
+				// Android / iOS App:renderjs input(鸿蒙不在 APP-PLUS,走下方 chooseOnUni)
+				// #ifdef APP-PLUS
+				this.chooseOnApp(option)
+				// #endif
+				// #ifdef MP-ALIPAY
+				this.chooseOnAlipay(option)
+				// #endif
+				// H5 / 小程序 / 鸿蒙 App(APP-HARMONY):uni.chooseFile + 后续 uni.uploadFile
+				// #ifndef APP-PLUS
+				// #ifndef MP-ALIPAY
+				this.chooseOnUni(option)
+				// #endif
+				// #endif
+			},
+			chooseOnUni(option) {
+				const { count, type, formats, size, multiple } = option
+				let chooseFn = uni.chooseFile
+				let extension = parseFormats(formats).map((e) => (e.startsWith('.') ? e : '.' + e))
+				// #ifdef MP-WEIXIN || MP-QQ
+				chooseFn =
+					typeof wx !== 'undefined' && typeof wx.chooseMessageFile === 'function'
+						? wx.chooseMessageFile
+						: uni.chooseMessageFile
+				extension = parseFormats(formats)
+				// #endif
+				if (typeof chooseFn !== 'function') {
+					this.tip('当前平台不支持选择文件')
+					return
+				}
+				const params = {
+					// 多选关键:count > 1;部分端还会看 multiple
+					count: Math.max(1, Number(count) || 1),
+					type: type || 'all',
+					success: (res) => {
+						this.handleChosenFiles(res.tempFiles || [], size)
+					},
+					fail: (err) => {
+						if (err && err.errMsg && /cancel|取消/i.test(err.errMsg)) return
+						this.tip((err && err.errMsg) || '选择文件失败')
+					}
+				}
+				if (multiple) params.multiple = true
+				if (formats) params.extension = extension
+				chooseFn(params)
+			},
+			// #ifdef MP-ALIPAY
+			chooseOnAlipay(option) {
+				const { count, type, formats, size } = option
+				const onFail = (err) => {
+					const msg = (err && (err.errorMessage || err.errMsg)) || ''
+					if (msg && !/cancel|取消|error:\s*11|error:\s*15/i.test(msg)) {
+						this.tip(msg)
+					}
+				}
+				const isImage =
+					type === 'image' ||
+					(parseFormats(formats).length > 0 &&
+						parseFormats(formats).every((e) => IMAGE_EXTS.has(e)))
+				if (isImage) {
+					const api =
+						typeof my !== 'undefined' && typeof my.chooseImage === 'function'
+							? my.chooseImage.bind(my)
+							: uni.chooseImage.bind(uni)
+					api({
+						count: Math.min(Math.max(count || 1, 1), 9),
+						sourceType: ['album', 'camera'],
+						success: (res) => {
+							const paths = res.tempFilePaths || res.apFilePaths || []
+							const files = (res.tempFiles || paths).map((f, i) => {
+								if (typeof f === 'string') {
+									return { name: inferName(f), path: f }
+								}
+								const path = f.path || paths[i]
+								let name = f.name || inferName(path)
+								if (/\.image$/i.test(name)) name = name.replace(/\.image$/i, '.jpg')
+								return { name, path, size: f.size }
+							})
+							this.handleChosenFiles(files, size)
+						},
+						fail: onFail
+					})
+					return
+				}
+				if (type === 'video') {
+					uni.chooseVideo({
+						sourceType: ['album', 'camera'],
+						success: (res) => {
+							this.handleChosenFiles(
+								[
+									{
+										name: (res.tempFile && res.tempFile.name) || inferName(res.tempFilePath),
+										path: res.tempFilePath,
+										size: res.size
+									}
+								],
+								size
+							)
+						},
+						fail: onFail
+					})
+					return
+				}
+				if (typeof my === 'undefined' || typeof my.chooseFileFromDisk !== 'function') {
+					this.tip('请升级支付宝客户端后重试')
+					return
+				}
+				my.chooseFileFromDisk({
+					success: (res) => {
+						if (!res || !res.apFilePath) {
+							onFail({ errMsg: 'empty' })
+							return
+						}
+						this.handleChosenFiles(
+							[
+								{
+									name: res.fileName || inferName(res.apFilePath),
+									path: res.apFilePath
+								}
+							],
+							size
+						)
+					},
+					fail: onFail
+				})
+			},
+			// #endif
+			// #ifdef APP-PLUS
+			chooseOnApp(option) {
+				this.appChooseToken = {
+					t: Date.now(),
+					count: Math.max(1, Number(option.count) || 1),
+					size: option.size,
+					formats: option.formats,
+					accept: option.accept || '',
+					// 明确传给 renderjs,避免仅靠 count 推断失败
+					multiple: option.multiple !== false && Number(option.count) > 1,
+					distinct: !!this.distinct
+				}
+			},
+			onAppFilesChosen(files) {
+				this.handleChosenFiles(files || [], this.maxSize, { fromApp: true })
+			},
+			onAppToast(msg) {
+				this.tip(msg)
+			},
+			async onAppReadDone(e) {
+				if (!e || !e.name) return
+				const map = this.readCallbackMap[e.name]
+				if (!map) return
+				if (e.status === 'fail') {
+					delete this.readCallbackMap[e.name]
+					if (typeof map.fail === 'function') map.fail(e)
+					return
+				}
+				if (e.result && e.result.tempFilePath) {
+					delete this.readCallbackMap[e.name]
+					if (typeof map.success === 'function') {
+						map.success({
+							name: e.name,
+							status: 'success',
+							result: e.result.tempFilePath
+						})
+					}
+					return
+				}
+				const chunkInfo = e.result || {}
+				if (chunkInfo.totalChunks != null) {
+					map.total = chunkInfo.totalChunks
+					map.chunks[chunkInfo.index] = chunkInfo.chunk
+					map.received.add(chunkInfo.index)
+					if (map.received.size < map.total) return
+					const base64 = map.chunks.filter((c) => c !== undefined).join('')
+					delete this.readCallbackMap[e.name]
+					try {
+						if (map.type === 'getBase') {
+							if (typeof map.success === 'function') {
+								map.success({
+									name: e.name,
+									status: 'success',
+									result: base64,
+									fileType: e.fileType || map.fileType
+								})
+							}
+						} else if (map.type === 'getArrayBuffer') {
+							if (typeof map.success === 'function') {
+								map.success({
+									name: e.name,
+									status: 'success',
+									result: this.base64ToArrayBuffer(base64),
+									fileType: e.fileType || map.fileType
+								})
+							}
+						} else if (map.type === 'getTempFilePath') {
+							const path = await this.base64ToPath(base64, e.name)
+							if (typeof map.success === 'function') {
+								map.success({
+									name: e.name,
+									status: 'success',
+									result: path
+								})
+							}
+							this.appReadToken = {
+								t: Date.now(),
+								action: 'asyncFileTempFilePath',
+								name: e.name,
+								tempFilePath: path
+							}
+						}
+					} catch (err) {
+						if (typeof map.fail === 'function') map.fail(err)
+					}
+					return
+				}
+				delete this.readCallbackMap[e.name]
+				if (typeof map.success === 'function') {
+					map.success({
+						name: e.name,
+						status: 'success',
+						result: e.result,
+						fileType: e.fileType || map.fileType
+					})
+				}
+			},
+			onAppUploadProgress(e) {
+				const cbMap = this.uploadCallbackMap[e.name]
+				if (cbMap && typeof cbMap.onprogress === 'function') {
+					cbMap.onprogress({
+						name: e.name,
+						progress: e.progress || 0,
+						status: 'onprogress'
+					})
+				}
+				const idx = this.innerList.findIndex((i) => i.appKey === e.name || i.name === e.name)
+				if (idx < 0) return
+				this.innerList[idx].progress = e.progress || 0
+				this.innerList[idx].status = 'uploading'
+			},
+			onAppUploadDone(e) {
+				const cb = this.appUploadCallbacks[e.name]
+				if (cb) {
+					delete this.appUploadCallbacks[e.name]
+					cb(e)
+				}
+			},
+			onAppPreviewReady(e) {
+				if (!e || e.status === 'fail') {
+					const item = this.innerList.find((i) => i.appKey === (e && e.name))
+					const fallback = item && (item.thumb || item.url || item.path)
+					if (fallback && !this.previewVisible) this.openInnerPreview(fallback)
+					else if (!this.previewVisible) this.tip((e && e.message) || '无法预览')
+					return
+				}
+				if (e.url) {
+					this.previewBlobUrls.push(e.url)
+					// 用原图 blob 替换;失败则回退到当前已显示的缩略图
+					this.openInnerPreview(e.url, this.previewSrc || this.previewFallback)
+				}
+			},
+			// #endif
+			onPreview(item) {
+				this.$emit('preview', { file: this.toPublicFile(item) })
+				if (!this.isImageItem(item)) {
+					const docPath = item.path || item.url
+					// #ifndef H5
+					if (docPath && this.isRemoteOrFileUrl(docPath)) {
+						uni.openDocument({
+							filePath: docPath,
+							showMenu: true,
+							fail: () => {
+								this.tip('无法预览该文件')
+							}
+						})
+					} else {
+						this.tip('无法预览该文件')
+					}
+					// #endif
+					// #ifdef H5
+					if (item.url && this.isRemoteOrFileUrl(item.url)) {
+						window.open(item.url, '_blank')
+					} else {
+						this.tip('无法预览该文件')
+					}
+					// #endif
+					return
+				}
+
+				const fileSrc = item.url || item.path
+				const thumb = item.thumb || ''
+
+				// 网络图 / 本地文件路径:系统预览(可左右滑多图)
+				if (fileSrc && this.isRemoteOrFileUrl(fileSrc)) {
+					const urls = this.innerList
+						.filter((i) => this.isImageItem(i))
+						.map((i) => i.url || i.path)
+						.filter((u) => u && this.isRemoteOrFileUrl(u))
+					uni.previewImage({
+						urls: urls.length ? urls : [fileSrc],
+						current: fileSrc,
+						fail: () => {
+							this.openInnerPreview(fileSrc || thumb)
+						}
+					})
+					return
+				}
+
+				// #ifdef APP-PLUS
+				// App 选图无本地 path:先用 thumb(dataURL)内置预览,再尝试原图 blob
+				if (item.appKey) {
+					if (thumb) {
+						this.openInnerPreview(thumb)
+						this.appPreviewToken = {
+							t: Date.now(),
+							action: 'open',
+							name: item.appKey
+						}
+						return
+					}
+					this.appPreviewToken = {
+						t: Date.now(),
+						action: 'open',
+						name: item.appKey
+					}
+					return
+				}
+				// #endif
+
+				// dataURL / 其它:组件内预览
+				const src = this.thumbSrc(item)
+				if (src) {
+					this.openInnerPreview(src)
+					return
+				}
+				this.tip('暂无可预览内容')
+			},
+			handleChosenFiles(tempFiles, sizeLimitMb, opts = {}) {
+				const maxBytes = sizeLimitMb > 0 ? 1024 * 1024 * Math.abs(sizeLimitMb) : 0
+				const allowExt = this.formatList
+				const accepted = []
+				const start = this.innerList.length
+
+				for (let i = 0; i < (tempFiles || []).length; i++) {
+					if (this.innerList.length + accepted.length >= this.maxCount) {
+						this.$emit('exceed', { maxCount: this.maxCount })
+						this.tip('最多上传 ' + this.maxCount + ' 个文件')
+						break
+					}
+					const f = tempFiles[i]
+					let name = f.name || inferName(f.path || f.tempFilePath)
+					const ext = getExt(name) || (f.type && String(f.type).split('/')[1]) || ''
+					const size = Number(f.size) || 0
+					const path = f.path || f.tempFilePath || ''
+					const thumb = f.thumb || ''
+					const mime = f.mime || f.type || ''
+
+					if (allowExt.length && ext && allowExt.indexOf(ext) === -1) {
+						this.tip('不支持 ' + ext.toUpperCase() + ' 格式')
+						continue
+					}
+					if (maxBytes > 0 && size > maxBytes) {
+						this.$emit('oversize', { name, path, size, maxSize: sizeLimitMb })
+						this.tip('文件不能超过 ' + sizeLimitMb + 'MB')
+						continue
+					}
+
+					// distinct=true:同名覆盖列表项(逻辑层)
+					if (this.distinct) {
+						const existIdx = this.innerList.findIndex((it) => it.name === name)
+						if (existIdx >= 0) {
+							const old = this.innerList[existIdx]
+							// #ifdef APP-PLUS
+							if (old.appKey) {
+								this.appRemoveToken = { name: old.appKey, t: Date.now() }
+							}
+							// #endif
+							this.innerList.splice(existIdx, 1)
+						}
+					}
+
+					const item = this.normalizeItem({
+						name,
+						path,
+						url: path,
+						thumb,
+						size,
+						ext,
+						mime,
+						status: 'ready',
+						progress: 0,
+						appKey: opts.fromApp ? name : ''
+					})
+					if (opts.fromApp) item.appKey = name
+					accepted.push(item)
+				}
+
+				if (!accepted.length) {
+					if (this.chooseFileFail) {
+						this.chooseFileFail({ status: 'fail', files: [] })
+						this.chooseFileSuccess = null
+						this.chooseFileFail = null
+					}
+					return
+				}
+				this.innerList = this.innerList.concat(accepted)
+				const publicAccepted = this.toEmitList(accepted)
+				this.$emit('select', {
+					files: publicAccepted,
+					count: accepted.length,
+					fileList: this.toEmitList()
+				})
+				this.emitChange({ selected: true })
+				if (this.chooseFileSuccess) {
+					this.chooseFileSuccess(
+						publicAccepted.map((f) => ({
+							...f,
+							status: f.status || 'waiting',
+							progress: f.progress || 0
+						}))
+					)
+					this.chooseFileSuccess = null
+					this.chooseFileFail = null
+				}
+				if (this.autoUpload && this.uploadUrl) {
+					this.$nextTick(() => {
+						const indexes = []
+						for (let i = 0; i < accepted.length; i++) indexes.push(start + i)
+						this.runUploadPool(indexes)
+					})
+				}
+			},
+			/**
+			 * upload({ url, file, name, method, header, formData, toBase, success, fail, onprogress })
+			 */
+			uploadByParams(params = {}) {
+				try {
+					if (!params.url) throw new Error('upload ERROR: url is undefined')
+					if (!params.file || !params.file.name) {
+						throw new Error('upload ERROR: file is undefined')
+					}
+					const file = params.file
+					const key = file.appKey || file.name
+					const header = params.header || {}
+					const formData = params.formData || {}
+					const name = params.name || 'file'
+					const method = (params.method || 'POST').toString().toUpperCase()
+					const toBase = params.toBase === true
+					const withCredentials =
+						params.withCredentials === true ||
+						this.resolvedUploadOptions.withCredentials
+
+					this.logDebug('uploadByParams', params.url, key)
+
+					// #ifdef APP-PLUS
+					if (file.appKey || (!file.path && key)) {
+						this.uploadCallbackMap[key] = {
+							success: params.success,
+							fail: params.fail,
+							onprogress: params.onprogress
+						}
+						this.appUploadCallbacks[key] = (e) => {
+							const cb = this.uploadCallbackMap[key]
+							delete this.uploadCallbackMap[key]
+							if (!cb) return
+							if (e.status === 'success' && typeof cb.success === 'function') {
+								cb.success({
+									name: key,
+									status: 'success',
+									message: '上传完成',
+									result: e.result
+								})
+							} else if (e.status !== 'abort' && typeof cb.fail === 'function') {
+								cb.fail(e)
+							}
+						}
+						this.appUploadToken = {
+							t: Date.now(),
+							url: params.url,
+							name,
+							header,
+							formData,
+							method,
+							toBase,
+							withCredentials,
+							fileName: key
+						}
+						return
+					}
+					// #endif
+
+					const filePath = file.path || file.url || file.tempFilePath
+					if (!filePath) throw new Error('upload ERROR: file path is undefined')
+					const task = uni.uploadFile({
+						url: params.url,
+						filePath,
+						name,
+						header,
+						formData,
+						success: (res) => {
+							if (res.statusCode >= 200 && res.statusCode < 300) {
+								if (typeof params.success === 'function') {
+									params.success({
+										name: file.name,
+										status: 'success',
+										message: '上传完成',
+										result: res.data
+									})
+								}
+							} else if (typeof params.fail === 'function') {
+								params.fail(res)
+							}
+						},
+						fail: (err) => {
+							if (typeof params.fail === 'function') params.fail(err)
+						}
+					})
+					if (task && typeof task.onProgressUpdate === 'function' && params.onprogress) {
+						task.onProgressUpdate(({ progress = 0 }) => {
+							params.onprogress({ name: file.name, progress })
+						})
+					}
+					return task
+				} catch (error) {
+					console.error(error.message || error)
+					if (typeof params.fail === 'function') params.fail(error)
+				}
+			},
+			uploadOne(index) {
+				const item = this.innerList[index]
+				if (!item || !this.uploadUrl) return Promise.resolve()
+				if (this.readonly || this.disabled) return Promise.resolve()
+				if (item.status === 'uploading') return Promise.resolve()
+
+				item.status = 'uploading'
+				item.progress = 0
+				item.message = ''
+
+				// Android/iOS App 无本地 path:renderjs XHR
+				// 鸿蒙等同 H5:有 path,走 uni.uploadFile(下面 uploadWithUni)
+				// #ifdef APP-PLUS
+				if (item.appKey) {
+					return this.uploadOnApp(index, item)
+				}
+				// #endif
+				return this.uploadWithUni(index, item)
+			},
+			uploadWithUni(index, item) {
+				const filePath = item.path || item.url
+				if (!filePath) {
+					item.status = 'fail'
+					item.message = '无本地路径'
+					this.$emit('uploadFail', {
+						index,
+						file: this.toPublicFile(item),
+						err: item.message
+					})
+					return Promise.resolve()
+				}
+				const opt = this.resolvedUploadOptions
+				const uid = item.uid
+				return new Promise((resolve) => {
+					let settled = false
+					const done = () => {
+						if (settled) return
+						settled = true
+						delete this.uploadTasks[uid]
+						resolve()
+					}
+					const task = uni.uploadFile({
+						url: this.uploadUrl,
+						filePath,
+						name: opt.name,
+						header: opt.header,
+						formData: opt.formData,
+						success: (res) => {
+							if (item.status !== 'uploading') {
+								done()
+								return
+							}
+							const payload = this.resolveResponsePayload(res.data, res)
+							if (res.statusCode >= 200 && res.statusCode < 300) {
+								item.status = 'success'
+								item.progress = 100
+								if (payload.url) {
+									item.url = payload.url
+									// 远程地址可作缩略图,本地 thumb 可保留作兜底
+								}
+								this.$emit('uploadSuccess', {
+									index,
+									file: this.toPublicFile(item),
+									url: item.url,
+									statusCode: res.statusCode,
+									data: payload.data,
+									res,
+									fileList: this.toEmitList()
+								})
+								this.emitChange()
+							} else {
+								item.status = 'fail'
+								item.message = 'HTTP ' + res.statusCode
+								this.$emit('uploadFail', {
+									index,
+									file: this.toPublicFile(item),
+									err: item.message,
+									statusCode: res.statusCode,
+									data: payload.data,
+									res
+								})
+							}
+							done()
+						},
+						fail: (err) => {
+							if (item.status !== 'uploading') {
+								done()
+								return
+							}
+							const msg = (err && err.errMsg) || '上传失败'
+							if (/abort/i.test(msg)) {
+								done()
+								return
+							}
+							item.status = 'fail'
+							item.message = msg
+							this.$emit('uploadFail', {
+								index,
+								file: this.toPublicFile(item),
+								err: item.message,
+								res: err
+							})
+							done()
+						}
+					})
+					this.uploadTasks[uid] = task
+					if (task && typeof task.onProgressUpdate === 'function') {
+						task.onProgressUpdate((e) => {
+							if (item.status === 'uploading') item.progress = e.progress || 0
+						})
+					}
+				})
+			},
+			// #ifdef APP-PLUS
+			uploadOnApp(index, item) {
+				const opt = this.resolvedUploadOptions
+				const key = item.appKey || item.name
+				const uid = item.uid
+				return new Promise((resolve) => {
+					let settled = false
+					const done = () => {
+						if (settled) return
+						settled = true
+						delete this.uploadTasks[uid]
+						resolve()
+					}
+					this.uploadTasks[uid] = {
+						abort: () => {
+							this.appAbortToken = { name: key, t: Date.now() }
+						}
+					}
+					this.appUploadCallbacks[key] = (e) => {
+						if (item.status !== 'uploading') {
+							done()
+							return
+						}
+						if (e.status === 'success') {
+							const payload = this.resolveResponsePayload(e.result, e)
+							item.status = 'success'
+							item.progress = 100
+							if (payload.url) item.url = payload.url
+							this.$emit('uploadSuccess', {
+								index,
+								file: this.toPublicFile(item),
+								url: item.url,
+								statusCode: 200,
+								data: payload.data,
+								res: e,
+								fileList: this.toEmitList()
+							})
+							this.emitChange()
+						} else if (e.status === 'abort') {
+							// abort() 已处理状态
+						} else {
+							item.status = 'fail'
+							item.message = e.message || '上传失败'
+							this.$emit('uploadFail', {
+								index,
+								file: this.toPublicFile(item),
+								err: item.message,
+								res: e
+							})
+						}
+						done()
+					}
+					this.appUploadToken = {
+						t: Date.now(),
+						url: this.uploadUrl,
+						name: opt.name,
+						header: opt.header,
+						formData: opt.formData,
+						method: opt.method || 'POST',
+						toBase: !!opt.toBase,
+						withCredentials: !!opt.withCredentials,
+						fileName: key
+					}
+				})
+			},
+			// #endif
+			onRetry(index) {
+				if (!this.uploadUrl || this.readonly || this.disabled) return
+				this.uploadOne(index)
+			},
+			async onRemove(index) {
+				if (this.readonly || this.disabled || !this.showRemove) return
+				const item = this.innerList[index]
+				if (!item || item.status === 'uploading') return
+
+				if (typeof this.beforeRemove === 'function') {
+					try {
+						const ok = await Promise.resolve(
+							this.beforeRemove({
+								index,
+								file: this.toPublicFile(item)
+							})
+						)
+						if (ok === false) return
+					} catch (e) {
+						return
+					}
+				}
+
+				const removed = this.innerList.splice(index, 1)[0]
+				// #ifdef APP-PLUS
+				if (removed && removed.appKey) {
+					this.appRemoveToken = { name: removed.appKey, t: Date.now() }
+				}
+				// #endif
+				this.$emit('remove', { index, file: this.toPublicFile(removed) })
+				this.emitChange()
+			}
+		}
+	}
+</script>
+
+<!-- #ifdef APP-PLUS -->
+<script module="render" lang="renderjs">
+	export default {
+		data() {
+			return {
+				files: new Map(),
+				xmls: new Map(),
+				inputEl: null
+			}
+		},
+		methods: {
+			chooseHandle(payload) {
+				if (!payload || !payload.t) return
+				const options = {
+					count: Number(payload.count) || 9,
+					size: Number(payload.size) || 20,
+					formats: String(payload.formats || '').toLowerCase(),
+					accept: payload.accept || '',
+					multiple: !!payload.multiple,
+					distinct: !!payload.distinct
+				}
+				const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'heic', 'heif']
+				/** App 选图无本地 path,生成小图 dataURL 供逻辑层 <image> 显示 */
+				const fileToThumb = (file) => {
+					return new Promise((resolve) => {
+						const blobUrl = URL.createObjectURL(file)
+						const img = new Image()
+						img.onload = () => {
+							try {
+								const maxW = 240
+								const scale = Math.min(1, maxW / (img.width || maxW))
+								const w = Math.max(1, Math.round((img.width || maxW) * scale))
+								const h = Math.max(1, Math.round((img.height || maxW) * scale))
+								const canvas = document.createElement('canvas')
+								canvas.width = w
+								canvas.height = h
+								const ctx = canvas.getContext('2d')
+								ctx.drawImage(img, 0, 0, w, h)
+								URL.revokeObjectURL(blobUrl)
+								resolve(canvas.toDataURL('image/jpeg', 0.72))
+							} catch (e) {
+								URL.revokeObjectURL(blobUrl)
+								resolve('')
+							}
+						}
+						img.onerror = () => {
+							URL.revokeObjectURL(blobUrl)
+							try {
+								const reader = new FileReader()
+								reader.onload = () => resolve(reader.result || '')
+								reader.onerror = () => resolve('')
+								reader.readAsDataURL(file)
+							} catch (e2) {
+								resolve('')
+							}
+						}
+						img.src = blobUrl
+					})
+				}
+				this.$nextTick(() => {
+					// 每次重建 input,避免安卓 WebView 缓存导致 multiple 不生效
+					let old = document.getElementById('kex-fu-input')
+					if (old && old.parentNode) {
+						try {
+							old.parentNode.removeChild(old)
+						} catch (e) {}
+					}
+					const input = document.createElement('input')
+					input.type = 'file'
+					input.id = 'kex-fu-input'
+					input.style.cssText = 'position:fixed;left:-9999px;opacity:0;width:0;height:0;'
+					const allowMultiple = !!(options.multiple && options.count > 1)
+					if (allowMultiple) {
+						input.multiple = true
+						input.setAttribute('multiple', 'multiple')
+					}
+					if (options.accept) {
+						input.accept = options.accept
+						input.setAttribute('accept', options.accept)
+					}
+					document.body.appendChild(input)
+					this.inputEl = input
+					input.value = ''
+					input.onchange = async () => {
+						const list = input.files || []
+						const out = []
+						for (let i = 0; i < list.length; i++) {
+							if (out.length >= options.count) {
+								this.$ownerInstance.callMethod('onAppToast', '最多选择' + options.count + '个文件')
+								break
+							}
+							const file = list[i]
+							const name = file.name
+							const suffix = name.substring(name.lastIndexOf('.') + 1).toLowerCase()
+							if (options.formats) {
+								const allow = options.formats.split(',').map((s) => s.trim()).filter(Boolean)
+								if (allow.length && allow.indexOf(suffix) === -1) {
+									this.$ownerInstance.callMethod('onAppToast', '不支持' + suffix.toUpperCase() + '格式')
+									continue
+								}
+							}
+							if (options.size > 0 && file.size > 1024 * 1024 * Math.abs(options.size)) {
+								this.$ownerInstance.callMethod('onAppToast', '文件不能超过' + options.size + 'MB')
+								continue
+							}
+							let key = name
+							if (options.distinct) {
+								// 同名覆盖:删除旧 File
+								this.files.delete(key)
+							} else {
+								let n = 1
+								while (this.files.has(key)) {
+									const dot = name.lastIndexOf('.')
+									const base = dot >= 0 ? name.substring(0, dot) : name
+									const ext = dot >= 0 ? name.substring(dot) : ''
+									key = base + '(' + n + ')' + ext
+									n += 1
+								}
+							}
+							this.files.set(key, file)
+							const isImage =
+								imageExts.indexOf(suffix) !== -1 ||
+								(file.type && String(file.type).indexOf('image/') === 0)
+							let thumb = ''
+							if (isImage) {
+								try {
+									thumb = await fileToThumb(file)
+								} catch (e) {
+									thumb = ''
+								}
+							}
+							out.push({
+								name: key,
+								size: file.size,
+								path: '',
+								thumb,
+								type: file.type || '',
+								mime: file.type || ''
+							})
+						}
+						this.$ownerInstance.callMethod('onAppFilesChosen', out)
+					}
+					input.click()
+				})
+			},
+			removeHandle(payload) {
+				if (!payload) return
+				if (payload.all) {
+					this.files.clear()
+					this.xmls.forEach((xhr) => {
+						try {
+							xhr.abort()
+						} catch (e) {}
+					})
+					this.xmls.clear()
+					return
+				}
+				if (payload.name) {
+					this.files.delete(payload.name)
+					const xhr = this.xmls.get(payload.name)
+					if (xhr) {
+						try {
+							xhr.abort()
+						} catch (e) {}
+						this.xmls.delete(payload.name)
+					}
+				}
+			},
+			abortHandle(payload) {
+				if (!payload || !payload.name) return
+				const xhr = this.xmls.get(payload.name)
+				if (xhr) {
+					try {
+						xhr.abort()
+					} catch (e) {}
+					this.xmls.delete(payload.name)
+				}
+				this.$ownerInstance.callMethod('onAppUploadDone', {
+					name: payload.name,
+					status: 'abort',
+					message: '已取消'
+				})
+			},
+			previewHandle(payload) {
+				if (!payload || !payload.t) return
+				if (payload.action === 'revoke') {
+					const urls = payload.urls || []
+					urls.forEach((u) => {
+						try {
+							URL.revokeObjectURL(u)
+						} catch (e) {}
+					})
+					return
+				}
+				if (payload.action !== 'open' || !payload.name) return
+				const file = this.files.get(payload.name)
+				if (!file) {
+					this.$ownerInstance.callMethod('onAppPreviewReady', {
+						name: payload.name,
+						status: 'fail',
+						message: '文件未找到'
+					})
+					return
+				}
+				try {
+					const url = URL.createObjectURL(file)
+					this.$ownerInstance.callMethod('onAppPreviewReady', {
+						name: payload.name,
+						status: 'success',
+						url
+					})
+				} catch (e) {
+					this.$ownerInstance.callMethod('onAppPreviewReady', {
+						name: payload.name,
+						status: 'fail',
+						message: '预览失败'
+					})
+				}
+			},
+			uploadHandle(payload) {
+				if (!payload || !payload.t || !payload.fileName) return
+				const fileEntry = this.files.get(payload.fileName)
+				const file = fileEntry && fileEntry.size != null && !fileEntry.slice ? fileEntry : (fileEntry && fileEntry.file) || fileEntry
+				if (!file) {
+					this.$ownerInstance.callMethod('onAppUploadDone', {
+						name: payload.fileName,
+						status: 'fail',
+						message: '文件未找到'
+					})
+					return
+				}
+				const run = async () => {
+					const form = new FormData()
+					const formData = payload.formData || {}
+					Object.keys(formData).forEach((k) => {
+						form.append(k, formData[k])
+					})
+					const fieldName = payload.name || 'file'
+					if (payload.toBase) {
+						const base64 = await this.fileToBase(payload.fileName)
+						form.append(fieldName, base64)
+					} else {
+						form.append(fieldName, file, payload.fileName)
+					}
+
+					const xhr = new XMLHttpRequest()
+					this.xmls.set(payload.fileName, xhr)
+					xhr.upload.onprogress = (event) => {
+						if (!event.lengthComputable) return
+						const progress = Math.ceil((event.loaded * 100) / event.total)
+						this.$ownerInstance.callMethod('onAppUploadProgress', {
+							name: payload.fileName,
+							progress,
+							status: 'onprogress'
+						})
+					}
+					xhr.onerror = () => {
+						this.xmls.delete(payload.fileName)
+						this.$ownerInstance.callMethod('onAppUploadDone', {
+							name: payload.fileName,
+							status: 'fail',
+							message: '网络错误'
+						})
+					}
+					xhr.ontimeout = () => {
+						this.xmls.delete(payload.fileName)
+						this.$ownerInstance.callMethod('onAppUploadDone', {
+							name: payload.fileName,
+							status: 'fail',
+							message: '请求超时'
+						})
+					}
+					xhr.onabort = () => {
+						this.xmls.delete(payload.fileName)
+					}
+					xhr.onreadystatechange = () => {
+						if (xhr.readyState !== 4) return
+						this.xmls.delete(payload.fileName)
+						if (xhr.status === 0) return
+						if (xhr.status >= 200 && xhr.status < 300) {
+							this.$ownerInstance.callMethod('onAppUploadDone', {
+								name: payload.fileName,
+								status: 'success',
+								message: '上传完成',
+								result: xhr.responseText
+							})
+						} else {
+							this.$ownerInstance.callMethod('onAppUploadDone', {
+								name: payload.fileName,
+								status: 'fail',
+								message: '上传失败 HTTP ' + xhr.status,
+								result: xhr.status
+							})
+						}
+					}
+					const method = (payload.method || 'POST').toString().toUpperCase()
+					xhr.open(method, payload.url, true)
+					// 跨域带 cookie(需服务端 Access-Control-Allow-Credentials)
+					xhr.withCredentials = !!payload.withCredentials
+					const header = payload.header || {}
+					Object.keys(header).forEach((k) => {
+						xhr.setRequestHeader(k, header[k])
+					})
+					if (this.isContentTypeJson(header)) {
+						const formObject = {}
+						if (form.entries) {
+							for (const pair of form.entries()) {
+								formObject[pair[0]] = pair[1]
+							}
+						}
+						xhr.send(JSON.stringify(formObject))
+					} else {
+						xhr.send(form)
+					}
+				}
+				run().catch((err) => {
+					this.$ownerInstance.callMethod('onAppUploadDone', {
+						name: payload.fileName,
+						status: 'fail',
+						message: (err && err.message) || '上传失败'
+					})
+				})
+			},
+			isContentTypeJson(header) {
+				try {
+					const contentType = header && (header['Content-Type'] || header['content-type'])
+					return contentType && String(contentType).indexOf('application/json') !== -1
+				} catch (e) {
+					return false
+				}
+			},
+			resolveFile(name) {
+				const entry = this.files.get(name)
+				if (!entry) return null
+				// 直接存 File,或 { file, tempFilePath, base64 }
+				if (entry.slice && entry.size != null) return entry
+				return entry.file || entry
+			},
+			fileToBase(name) {
+				return new Promise((resolve, reject) => {
+					const entry = this.files.get(name)
+					if (!entry) return reject(new Error('文件未找到'))
+					if (entry.base64) {
+						return resolve(String(entry.base64).replace(/[\r\n]/g, ''))
+					}
+					const file = this.resolveFile(name)
+					if (!file) return reject(new Error('文件未找到'))
+					const reader = new FileReader()
+					reader.onload = (e) => {
+						let base64 = e.target.result
+						if (entry && typeof entry === 'object' && !entry.slice) {
+							entry.base64 = base64
+							this.files.set(name, entry)
+						}
+						resolve(String(base64).replace(/[\r\n]/g, ''))
+					}
+					reader.onerror = (e) => reject(e)
+					reader.readAsDataURL(file)
+				})
+			},
+			sliceToBase64(slice) {
+				return new Promise((resolve, reject) => {
+					const reader = new FileReader()
+					reader.onload = (e) => {
+						resolve(e.target.result.split(',')[1])
+					}
+					reader.onerror = (e) => reject(e)
+					reader.readAsDataURL(slice)
+				})
+			},
+			async readHandle(payload) {
+				if (!payload || !payload.t || !payload.name) return
+				const name = payload.name
+				const action = payload.action
+				if (action === 'asyncFileTempFilePath') {
+					const entry = this.files.get(name)
+					if (entry && typeof entry === 'object' && !entry.slice) {
+						entry.tempFilePath = payload.tempFilePath
+						this.files.set(name, entry)
+					} else if (entry) {
+						this.files.set(name, { file: entry, tempFilePath: payload.tempFilePath })
+					}
+					return
+				}
+				const file = this.resolveFile(name)
+				if (!file) {
+					this.$ownerInstance.callMethod('onAppReadDone', {
+						name,
+						status: 'fail',
+						message: '文件未找到'
+					})
+					return
+				}
+				try {
+					if (action === 'getTempFilePath') {
+						const entry = this.files.get(name)
+						const cached =
+							entry && entry.tempFilePath
+								? entry.tempFilePath
+								: null
+						if (cached) {
+							this.$ownerInstance.callMethod('onAppReadDone', {
+								name,
+								status: 'success',
+								result: { tempFilePath: cached }
+							})
+							return
+						}
+					}
+					const chunkSize = 5 * 1024 * 1024
+					const totalSize = file.size
+					const totalChunks = Math.ceil(totalSize / chunkSize) || 1
+					const fileType = file.type || ''
+					for (let i = 0; i < totalChunks; i++) {
+						const start = i * chunkSize
+						const end = Math.min(start + chunkSize, totalSize)
+						const chunk = file.slice(start, end)
+						const base64Chunk = await this.sliceToBase64(chunk)
+						this.$ownerInstance.callMethod('onAppReadDone', {
+							name,
+							fileType,
+							status: 'success',
+							result: {
+								chunk: base64Chunk,
+								index: i,
+								totalChunks
+							}
+						})
+					}
+				} catch (error) {
+					this.$ownerInstance.callMethod('onAppReadDone', {
+						name,
+						status: 'fail',
+						message: (error && error.message) || '读取失败'
+					})
+				}
+			}
+		}
+	}
+</script>
+<!-- #endif -->
+
+<style scoped>
+	.kex-fu {
+		width: 100%;
+	}
+
+	.kex-fu--disabled {
+		opacity: 0.6;
+	}
+
+	.kex-fu--headless {
+		width: 0;
+		height: 0;
+		overflow: hidden;
+		opacity: 0;
+		position: fixed;
+		left: -9999px;
+	}
+
+	.kex-fu__item {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		padding: 20rpx 0;
+		border-bottom: 1px solid #f0f0f0;
+	}
+
+	.kex-fu__thumb {
+		width: 72rpx;
+		height: 72rpx;
+		border-radius: 12rpx;
+		margin-right: 20rpx;
+		flex-shrink: 0;
+		background: #f5f6f8;
+	}
+
+	.kex-fu__icon {
+		width: 72rpx;
+		height: 72rpx;
+		border-radius: 12rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		margin-right: 20rpx;
+		flex-shrink: 0;
+		background: #eef2ff;
+	}
+
+	.kex-fu__icon--image {
+		background: #e8f5e9;
+	}
+
+	.kex-fu__icon--doc {
+		background: #e3f2fd;
+	}
+
+	.kex-fu__icon--video {
+		background: #fce4ec;
+	}
+
+	.kex-fu__icon--audio {
+		background: #fff3e0;
+	}
+
+	.kex-fu__icon-text {
+		font-size: 24rpx;
+		color: #2979ff;
+		font-weight: 600;
+	}
+
+	.kex-fu__body {
+		flex: 1;
+		min-width: 0;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.kex-fu__name {
+		font-size: 28rpx;
+		color: #303133;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	.kex-fu__meta {
+		margin-top: 6rpx;
+		font-size: 22rpx;
+		color: #909399;
+	}
+
+	.kex-fu__fail {
+		color: #f56c6c;
+	}
+
+	.kex-fu__ok {
+		color: #67c23a;
+	}
+
+	.kex-fu__bar {
+		margin-top: 10rpx;
+		height: 6rpx;
+		background: #ebeef5;
+		border-radius: 4rpx;
+		overflow: hidden;
+	}
+
+	.kex-fu__bar-inner {
+		height: 100%;
+		background: #2979ff;
+		border-radius: 4rpx;
+	}
+
+	.kex-fu__actions {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		margin-left: 12rpx;
+		flex-shrink: 0;
+	}
+
+	.kex-fu__abort,
+	.kex-fu__retry {
+		font-size: 24rpx;
+		color: #2979ff;
+		padding: 8rpx 12rpx;
+	}
+
+	.kex-fu__abort {
+		color: #e6a23c;
+	}
+
+	.kex-fu__remove {
+		width: 48rpx;
+		height: 48rpx;
+		line-height: 48rpx;
+		text-align: center;
+		font-size: 36rpx;
+		color: #c0c4cc;
+	}
+
+	.kex-fu__add {
+		margin-top: 16rpx;
+		padding: 28rpx;
+		border: 1px dashed #dcdfe6;
+		border-radius: 12rpx;
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		justify-content: center;
+		background: #fafafa;
+	}
+
+	.kex-fu__add-icon {
+		font-size: 36rpx;
+		color: #2979ff;
+		margin-right: 8rpx;
+		line-height: 1;
+	}
+
+	.kex-fu__add-text {
+		font-size: 28rpx;
+		color: #606266;
+	}
+
+	.kex-fu__add-count {
+		margin-left: 12rpx;
+		font-size: 22rpx;
+		color: #909399;
+	}
+
+	.kex-fu__render {
+		position: fixed;
+		left: -9999px;
+		width: 0;
+		height: 0;
+		overflow: hidden;
+		opacity: 0;
+	}
+
+	.kex-fu__preview {
+		position: fixed;
+		left: 0;
+		right: 0;
+		top: 0;
+		bottom: 0;
+		z-index: 99999;
+		background: rgba(0, 0, 0, 0.92);
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.kex-fu__preview-img {
+		width: 100%;
+		height: 80%;
+	}
+
+	.kex-fu__preview-close {
+		margin-top: 24rpx;
+		font-size: 26rpx;
+		color: rgba(255, 255, 255, 0.75);
+	}
+</style>

+ 102 - 0
uni_modules/kex-file-uploader/package.json

@@ -0,0 +1,102 @@
+{
+	"id": "kex-file-uploader",
+	"displayName": "任意文件 附件上传 选择任意附件 附件上传 |App全端|H5|小程序",
+	"version": "1.2.0",
+	"description": "附件上传(Vue3):任意文件、多端含鸿蒙、列表/缩略图、多端支持chooseFile选择文件、uploadFile上传文件",
+	"keywords": [
+        "kex-file-uploader",
+        "upload",
+        "附件上传",
+        "文件上传",
+        "chooseFile"
+    ],
+	"repository": "",
+	"engines": {
+		"HBuilderX": "^3.1.0",
+		"uni-app": "^3.8.0",
+		"uni-app-x": ""
+	},
+	"dcloudext": {
+		"type": "component-vue",
+		"sale": {
+			"regular": {
+				"price": "0.00"
+			},
+			"sourcecode": {
+				"price": "0.00"
+			}
+		},
+		"contact": {
+			"qq": ""
+		},
+		"declaration": {
+			"ads": "无",
+			"data": "无",
+			"permissions": "文件系统/相册(选文件时由系统授权)"
+		},
+		"npmurl": "",
+		"darkmode": "x",
+		"i18n": "x",
+		"widescreen": "x"
+	},
+	"uni_modules": {
+		"dependencies": [],
+		"encrypt": [],
+		"platforms": {
+			"cloud": {
+				"tcb": "x",
+				"aliyun": "x",
+				"alipay": "x"
+			},
+			"client": {
+				"uni-app": {
+					"vue": {
+						"vue2": "√",
+						"vue3": "√"
+					},
+					"web": {
+						"safari": "√",
+						"chrome": "√"
+					},
+					"app": {
+						"vue": "√",
+						"nvue": "-",
+						"android": "√",
+						"ios": "√",
+						"harmony": "√"
+					},
+					"mp": {
+						"weixin": "√",
+						"alipay": "-",
+						"toutiao": "-",
+						"baidu": "-",
+						"kuaishou": "-",
+						"jd": "-",
+						"harmony": "√",
+						"qq": "-",
+						"lark": "-",
+						"xhs": "-"
+					},
+					"quickapp": {
+						"huawei": "-",
+						"union": "-"
+					}
+				},
+				"uni-app-x": {
+					"web": {
+						"safari": "-",
+						"chrome": "-"
+					},
+					"app": {
+						"android": "-",
+						"ios": "-",
+						"harmony": "-"
+					},
+					"mp": {
+						"weixin": "-"
+					}
+				}
+			}
+		}
+	}
+}

+ 652 - 0
uni_modules/kex-file-uploader/readme.md

@@ -0,0 +1,652 @@
+# kex-file-uploader
+
+附件上传组件(uni-app **Vue3**):可选图片、PDF、Office 等任意文件,支持列表展示、进度上传、自定义 UI。
+
+支持:**H5 / App(vue 页)/ 微信小程序 / 支付宝小程序 / 鸿蒙** 等主流端。
+
+---
+
+## 目录
+
+1. [功能一览](#功能一览)
+2. [安装](#安装)
+3. [三种用法怎么选](#三种用法怎么选)
+4. [快速上手](#快速上手)
+5. [完整示例](#完整示例)
+6. [Props](#props)
+7. [Events](#events)
+8. [方法 ref](#方法-ref)
+9. [插槽](#插槽)
+10. [v-model 数据结构](#v-model-数据结构)
+11. [上传与响应解析](#上传与响应解析)
+12. [多端说明](#多端说明)
+13. [常见问题](#常见问题)
+14. [注意事项](#注意事项)
+15. [版本](#版本)
+
+---
+
+## 功能一览
+
+| 能力 | 说明 |
+| --- | --- |
+| 任意文件 | 图片 / 文档 / 压缩包等;`formats` 为空则不限制(后端不拦即可) |
+| 单次多选 | `multiple` 默认 `true`,受 `maxCount` 剩余名额限制 |
+| 内置列表 | 文件名、大小、进度、删除、重试、取消;图片缩略图 |
+| 自定义按钮 | `#trigger` 完全自定义,或 `show-trigger=false` + 外部按钮 |
+| 自定义列表 | `#item` 改每一行,或 `mode="headless"` 完全自绘 |
+| 自动 / 手动上传 | `autoUpload` + `uploadUrl`;也可 `ref.uploadAll()` |
+| 上传配置 | `uploadOptions`:`name` / `header` / `formData`(有默认值,只填 url 也能传) |
+| 并发 / 取消 | `concurrent`(默认 3)、`abort` / `abortAll` |
+| 响应解析 | `responseUrlKey` / `parseResponse` |
+| 只读 / 确认删 | `readonly`、`beforeRemove` |
+
+---
+
+## 安装
+
+将插件拷贝到业务项目:
+
+```text
+你的项目/uni_modules/kex-file-uploader/
+```
+
+uni-app **easycom** 会自动注册,页面里直接写标签,无需 `import`。
+
+要求:
+
+- uni-app **Vue3**
+- App 请使用 **vue 页面**(不要用 nvue;App 选任意文件依赖 renderjs)
+
+演示页(本示例工程):首页 → `kex-file-uploader`,或路径 `/pages/demos/file-uploader/index`。
+
+---
+
+## 三种用法怎么选
+
+| 场景 | 写法 | 内置按钮 | 内置列表 |
+| --- | --- | --- | --- |
+| 开箱即用 | 默认 `mode="list"` | ✅ | ✅ |
+| 按钮自己画,列表用默认 | `:show-trigger="false"` + `ref.choose()` | ❌ | ✅ |
+| 按钮和列表都自己画 | `mode="headless"` + `ref.choose()` + 自己 `v-for` | ❌ | ❌ |
+
+`show-trigger="false"` / `headless` 时,组件可放在页面任意位置,只要 `ref` 能调到即可。
+
+**其它 Props(`uploadUrl`、`multiple`、`maxCount`、`formats` 等)在三种模式下都生效。**
+
+---
+
+## 快速上手
+
+### 1. 只选文件(不上传)
+
+```vue
+<template>
+  <kex-file-uploader v-model="files" :max-count="5" />
+</template>
+
+<script setup>
+import { ref } from 'vue'
+const files = ref([])
+</script>
+```
+
+### 2. 自动上传(最少只填接口)
+
+`name` / `header` / `formData` 有默认值,只配 `upload-url` 即可发起 `uni.uploadFile`。
+
+```vue
+<template>
+  <kex-file-uploader
+    v-model="files"
+    :auto-upload="true"
+    upload-url="https://example.com/api/upload"
+    response-url-key="data.url"
+    @uploadSuccess="onOk"
+    @uploadFail="onFail"
+  />
+</template>
+
+<script setup>
+import { ref } from 'vue'
+const files = ref([])
+const onOk = (e) => console.log('成功', e.url, e.data)
+const onFail = (e) => console.log('失败', e.err)
+</script>
+```
+
+### 3. 带 Token / 业务字段
+
+```vue
+<script setup>
+import { ref, computed } from 'vue'
+
+const files = ref([])
+const token = ref('Bearer xxx')
+
+const uploadOptions = computed(() => ({
+  // name 默认 'file',可不写
+  header: { Authorization: token.value },
+  // 值必须是字符串
+  formData: { folderId: '123', module: 'attach' }
+}))
+</script>
+
+<template>
+  <kex-file-uploader
+    v-model="files"
+    :auto-upload="true"
+    upload-url="https://example.com/api/upload"
+    :upload-options="uploadOptions"
+  />
+</template>
+```
+
+### 4. 限制类型、大小、多选
+
+```vue
+<!-- 单次可多选,列表最多 9 个;仅 pdf/word;单文件 ≤ 10MB -->
+<kex-file-uploader
+  v-model="files"
+  :max-count="9"
+  :multiple="true"
+  :max-size="10"
+  formats="pdf,doc,docx"
+/>
+
+<!-- 单次只能选 1 个,仍可多次点选直到 maxCount -->
+<kex-file-uploader v-model="files" :max-count="9" :multiple="false" />
+```
+
+### 5. 删除前确认
+
+```vue
+<script setup>
+const beforeRemove = ({ file }) =>
+  new Promise((resolve) => {
+    uni.showModal({
+      title: '确认删除',
+      content: `删除「${file.name}」?`,
+      success: (r) => resolve(!!r.confirm),
+      fail: () => resolve(false)
+    })
+  })
+</script>
+
+<template>
+  <kex-file-uploader v-model="files" :before-remove="beforeRemove" />
+</template>
+```
+
+### 6. 只读回显
+
+```vue
+<script setup>
+const files = ref([
+  { name: '合同.pdf', url: 'https://cdn.example.com/a.pdf', size: 102400, status: 'success' }
+])
+</script>
+
+<template>
+  <kex-file-uploader v-model="files" :readonly="true" />
+</template>
+```
+
+### 7. 自定义选择按钮(保留默认列表)
+
+```vue
+<!-- 方式 A:插槽完全替换按钮 UI -->
+<kex-file-uploader v-model="files" :max-count="5">
+  <template #trigger="{ choose, count, maxCount }">
+    <view class="my-btn" @click="choose">上传材料 {{ count }}/{{ maxCount }}</view>
+  </template>
+</kex-file-uploader>
+
+<!-- 方式 B:隐藏内置按钮,外部任意位置调用 -->
+<kex-file-uploader ref="fu" v-model="files" :show-trigger="false" />
+<button @click="fu.choose()">选择附件</button>
+```
+
+### 8. 自定义列表行(保留组件结构)
+
+```vue
+<kex-file-uploader v-model="files">
+  <template #item="{ file, remove, retry, abort, preview }">
+    <view @click="preview">
+      <text>{{ file.name }} · {{ file.status }}</text>
+      <text v-if="file.status === 'uploading'" @click.stop="abort">取消</text>
+      <text v-else-if="file.status === 'fail'" @click.stop="retry">重试</text>
+      <text v-else @click.stop="remove">删除</text>
+    </view>
+  </template>
+</kex-file-uploader>
+```
+
+### 9. 外部打开 + 完全自定义列表(headless)
+
+无内置按钮、无内置列表;组件可藏在页面任意处。
+
+```vue
+<template>
+  <kex-file-uploader
+    ref="fu"
+    v-model="files"
+    mode="headless"
+    :max-count="9"
+    :multiple="true"
+    :auto-upload="true"
+    upload-url="https://example.com/api/upload"
+    :upload-options="uploadOptions"
+    @select="onSelect"
+    @uploadSuccess="onOk"
+    @uploadFail="onFail"
+  />
+
+  <button @click="fu.choose()">选择附件</button>
+  <button @click="fu.uploadAll()">上传全部</button>
+  <button @click="fu.clear()">清空</button>
+
+  <view v-for="(f, i) in files" :key="f.uid || i">
+    <text>{{ f.name }} · {{ f.status }} {{ f.progress }}%</text>
+    <text v-if="f.status === 'uploading'" @click="fu.abort(i)">取消</text>
+    <text v-else-if="f.status === 'fail'" @click="fu.upload(i)">重试</text>
+    <text @click="files.splice(i, 1)">删除</text>
+  </view>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+const fu = ref(null)
+const files = ref([])
+const uploadOptions = computed(() => ({
+  header: { Authorization: 'Bearer xxx' }
+}))
+
+const onSelect = (e) => console.log('选中', e.count, e.fileList)
+const onOk = (e) => console.log('成功', e.url, e.data)
+const onFail = (e) => console.log('失败', e.err)
+</script>
+```
+
+### 10. 手动上传(先选后传)
+
+```vue
+<template>
+  <kex-file-uploader
+    ref="fu"
+    v-model="files"
+    :auto-upload="false"
+    upload-url="https://example.com/api/upload"
+  />
+  <button @click="fu.uploadAll()">开始上传</button>
+</template>
+```
+
+---
+
+## 完整示例
+
+业务页常见写法(自动上传 + Token + 确认删除 + 事件):
+
+```vue
+<template>
+  <kex-file-uploader
+    v-model="files"
+    :max-count="9"
+    :max-size="20"
+    :multiple="true"
+    formats=""
+    :auto-upload="true"
+    upload-url="https://example.com/api/upload"
+    :upload-options="uploadOptions"
+    response-url-key="data.url"
+    :concurrent="3"
+    :before-remove="beforeRemove"
+    @select="onSelect"
+    @change="onChange"
+    @uploadSuccess="onOk"
+    @uploadFail="onFail"
+    @uploadAbort="onAbort"
+    @remove="onRemove"
+  />
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+const files = ref([])
+const token = ref('') // 从登录态取值
+
+const uploadOptions = computed(() => ({
+  name: 'file',
+  header: token.value ? { Authorization: token.value } : {},
+  formData: { module: 'attachment' }
+}))
+
+const beforeRemove = ({ file }) =>
+  new Promise((resolve) => {
+    uni.showModal({
+      title: '确认删除',
+      content: `删除「${file.name}」?`,
+      success: (r) => resolve(!!r.confirm)
+    })
+  })
+
+const onSelect = (e) => console.log('选图', e.count)
+const onChange = (e) => console.log('列表', e.fileList)
+const onOk = (e) => console.log('成功', e.url, e.data)
+const onFail = (e) => console.log('失败', e.err)
+const onAbort = (e) => console.log('取消', e.file)
+const onRemove = (e) => console.log('删除', e.index, e.file)
+</script>
+```
+
+提交表单时,一般只取已成功项的远程地址:
+
+```js
+const urls = files.value
+  .filter((f) => f.status === 'success' && f.url)
+  .map((f) => f.url)
+```
+
+---
+
+## Props
+
+| 属性 | 类型 | 默认 | 说明 |
+| --- | --- | --- | --- |
+| modelValue / v-model | `Array` | `[]` | 文件列表,见下方结构 |
+| mode | `String` | `'list'` | `list` 带列表 UI;`headless` 无 UI 纯能力 |
+| maxCount | `Number` | `9` | 列表最多文件数 |
+| multiple | `Boolean` | `true` | 单次能否多选;`false` 则每次只能选 1 个 |
+| maxSize | `Number` | `20` | 单文件上限 MB;`0` 表示不限制 |
+| formats | `String \| Array` | `''` | 允许扩展名,空=不限制。如 `'pdf,png'` 或 `['pdf','png']` |
+| fileType | `String` | `'all'` | 选文件类型提示:`all` / `image` / `video` / `file` |
+| accept | `String` | `''` | H5 / App 原生 input 的 accept,如 `image/*`、`.pdf,.doc` |
+| disabled | `Boolean` | `false` | 禁用选择 / 删除 / 上传 |
+| readonly | `Boolean` | `false` | 只读展示,不可选、不可删、不可传 |
+| showRemove | `Boolean` | `true` | 是否显示删除 |
+| showCount | `Boolean` | `true` | 默认按钮上是否显示数量 |
+| showThumb | `Boolean` | `true` | 图片是否显示缩略图 |
+| addText | `String` | `'添加附件'` | 默认选择按钮文案 |
+| showTrigger | `Boolean` | `true` | 是否显示内置选择按钮;`false` 时用外部 `ref.choose()` |
+| triggerPosition | `String` | `'bottom'` | 内置按钮位置:`bottom` / `top` |
+| addClass | `String \| Array \| Object` | `''` | 默认按钮额外 class |
+| addStyle | `Object \| String` | `{}` | 默认按钮内联样式 |
+| toast | `Boolean` | `true` | 超限等是否 toast |
+| autoUpload | `Boolean` | `false` | 选完是否自动上传 |
+| uploadUrl | `String` | `''` | 上传接口(对应 `uni.uploadFile` 的 url) |
+| uploadOptions | `Object` | 见下表 | 上传参数 |
+| concurrent | `Number` | `3` | 并发上传数;≤0 按 1 处理 |
+| responseUrlKey | `String` | `''` | 从响应取远程 url 的路径,如 `data.url`、`data.fileUrl` |
+| parseResponse | `Function` | `null` | `(raw, res) => url \| { url, data }`,优先级最高 |
+| beforeRemove | `Function` | `null` | `({ index, file }) => boolean \| Promise`,返回 `false` 拦截删除 |
+
+### uploadOptions
+
+对应 `uni.uploadFile`,未传字段自动补默认,**只填 `uploadUrl` 也能上传**:
+
+| 字段 | 默认 | 说明 |
+| --- | --- | --- |
+| name | `'file'` | 文件字段名 |
+| header | `{}` | 请求头(可用 computed 动态带 Token) |
+| formData | `{}` | 额外表单字段;**值必须是字符串** |
+| method | `'POST'` | App(Android/iOS)XHR 方法;其它端仍走 `uni.uploadFile`(POST) |
+| toBase | `false` | App:以 Base64 字符串作为字段值上传 |
+| withCredentials | `false` | App XHR 是否跨域携带 cookie(需服务端 CORS 允许 credentials) |
+
+```js
+{
+  name: 'file',
+  header: { Authorization: 'Bearer xxx' },
+  formData: { bizId: '1', type: 'attach' },
+  method: 'POST',
+  // toBase: true, // App 需要 Base64 字段时
+  withCredentials: true // iOS 本地测远程且要带 cookie
+}
+```
+
+另有 props:`debug`(日志)、`distinct`(同名覆盖,默认重命名)。
+
+### 关于跨域(CORS)
+
+跨域需服务端配置 CORS,组件侧可配合 `withCredentials`:
+
+- **Android/iOS App**:webview **XHR** → 需服务端 CORS。  
+- **鸿蒙 / H5 / 小程序**:`uni.uploadFile`,同样要服务端允许跨域。  
+- 带 cookie:`uploadOptions.withCredentials: true` + 服务端 `Access-Control-Allow-Credentials: true`(Origin 不能是 `*`)。
+
+---
+
+## Events
+
+| 事件 | 触发时机 | 回调参数 |
+| --- | --- | --- |
+| update:modelValue | 列表变化 | `fileList`(同 v-model) |
+| change | 列表任意变化 | `{ fileList, ... }` |
+| select | 每次选文件完成 | 见下方 |
+| uploadSuccess | 单文件上传成功 | 见下方 |
+| uploadFail | 单文件上传失败 | `{ index, file, err, statusCode?, data?, res? }` |
+| uploadAbort | 取消上传 | `{ index, file }` |
+| remove | 删除(内置删除按钮) | `{ index, file }` |
+| oversize | 单文件超体积 | `{ name, path, size, maxSize }` |
+| exceed | 超过 maxCount | `{ maxCount }` |
+| preview | 点击预览 | `{ file }` |
+
+### `@select`
+
+```js
+{
+  files,     // 本次新增:与 v-model 单项同结构
+  count,     // 本次新增数量
+  fileList   // 当前全部
+}
+```
+
+### `@uploadSuccess`
+
+```js
+{
+  index,
+  file,        // 当前文件
+  url,         // 优先为解析出的远程地址
+  statusCode,
+  data,        // 解析后的服务端 JSON(失败则为原始字符串)
+  res,         // 原始回调
+  fileList     // 当前全部
+}
+```
+
+### `@change`
+
+```js
+{
+  fileList   // 当前全部文件
+}
+```
+
+---
+
+## 方法(ref)
+
+```vue
+<kex-file-uploader ref="fu" v-model="files" upload-url="..." />
+```
+
+| 方法 | 说明 |
+| --- | --- |
+| `choose()` | 打开系统选文件 |
+| `chooseFile({ success, fail, count, ... })` | 回调式选文件(可带 count/formats/size 等) |
+| `upload(index)` | 上传指定下标 |
+| `upload({ url, file, success, fail, onprogress, ... })` | 命令式上传单文件 |
+| `upload()` / `uploadAll()` | 上传所有 `ready` / `fail` 项(受 `concurrent` 控制) |
+| `abort(index)` | 取消指定上传中的项 |
+| `abortAll()` | 取消全部上传中 |
+| `clear()` | 清空列表(会先 abort) |
+| `getFileList()` | 返回当前列表副本 |
+| `openDocument({ file, success, fail })` | 打开文档预览 |
+| `getTempFilePath` / `getBase` / `getArrayBuffer` | 读临时路径 / Base64 / ArrayBuffer |
+
+```js
+fu.value.choose()
+fu.value.uploadAll()
+fu.value.abort(0)
+fu.value.clear()
+console.log(fu.value.getFileList())
+
+// 回调式选文件 / 命令式上传
+fu.value.chooseFile({
+  count: 3,
+  success: (files) => console.log(files)
+})
+fu.value.upload({
+  url: 'https://xxx/upload',
+  file: files[0],
+  header: { Authorization: 'Bearer xxx' },
+  success: (e) => console.log(e.result),
+  fail: console.error,
+  onprogress: (e) => console.log(e.progress)
+})
+```
+
+---
+
+## 插槽
+
+| 插槽 | 说明 | 作用域参数 |
+| --- | --- | --- |
+| `trigger` | **完全自定义**选择按钮(推荐);达上限或 disabled/readonly 时不渲染 | `choose`、`count`、`maxCount`、`disabled`、`readonly` |
+| `add` | 只替换默认虚线框里的内容 | `count`、`maxCount` |
+| `item` | 完全自定义每一行列表 | `file`、`index`、`remove`、`retry`、`abort`、`preview` |
+
+点击 `trigger` 里请调用插槽传入的 `choose()`(或外部 `ref.choose()`)。
+
+---
+
+## v-model 数据结构
+
+每一项大致为:
+
+```js
+{
+  uid: 'kex-fu-...',   // 内部唯一 id
+  name: '报告.pdf',
+  url: 'https://...',  // 成功后优先为远程地址;App 选中未上传时可能为空
+  path: '',            // 本地路径(上传用;App 选任意文件时可能为空)
+  size: 102400,        // 字节
+  ext: 'pdf',
+  status: 'success',   // ready | uploading | success | fail
+  progress: 100,       // 0~100
+  message: ''          // 失败等原因文案
+}
+```
+
+| status | 含义 |
+| --- | --- |
+| ready | 已选中,待上传 |
+| uploading | 上传中 |
+| success | 成功(回显远程文件也是此状态) |
+| fail | 失败或已取消,可重试 |
+
+说明:
+
+- App 选图会额外在内部保留 `thumb`(缩略图 dataURL)用于展示,**不会**把大段 base64 写进 `v-model` 的 `url`
+- 提交后端时建议只提交 `status === 'success'` 的 `url` / `name` 等业务字段
+
+---
+
+## 上传与响应解析
+
+底层:非 App 用 `uni.uploadFile`;App 用 renderjs + `XMLHttpRequest`。
+
+成功条件:HTTP 状态码 `2xx`。
+
+远程地址解析优先级:
+
+1. **`parseResponse`**(最高)  
+2. **`responseUrlKey`**(如 `data.url`、`data.fileUrl`)  
+3. 内置字段:`url` / `path` / `data.url` / `data.path` / `data.fileUrl` / `result.url`
+
+```vue
+<!-- 方式 A:路径取值 -->
+<kex-file-uploader response-url-key="data.url" ... />
+
+<!-- 方式 B:自定义函数 -->
+<kex-file-uploader
+  :parse-response="(raw) => {
+    const d = typeof raw === 'string' ? JSON.parse(raw) : raw
+    return (d && d.data && d.data.fileUrl) || ''
+  }"
+  ...
+/>
+```
+
+也可在 `@uploadSuccess` 里用 `e.data` 自行处理,再改 `v-model`。
+
+---
+
+## 多端说明
+
+| 端 | 选文件方式 | 上传 | 多选 |
+| --- | --- | --- | --- |
+| H5 | `uni.chooseFile` | `uni.uploadFile` | ✅ |
+| 微信 / QQ | `chooseMessageFile`(聊天会话) | `uni.uploadFile` | ✅ |
+| 支付宝 | 图片相册 / 其它 `chooseFileFromDisk` | `uni.uploadFile` | 本地多为单选 |
+| **Android / iOS App** | renderjs `<input type="file">` | renderjs **XHR** | ✅(个别安卓管理器可能单选) |
+| **鸿蒙 App** | **`uni.chooseFile`**(**不走 renderjs**) | **`uni.uploadFile`** | 以系统能力为准 |
+
+说明:uni-app 里 **`APP-PLUS` 不含鸿蒙**;鸿蒙走 `#ifndef APP-PLUS` 的选传链路。不要把「App = 必须 renderjs」理解成鸿蒙也要 renderjs。
+
+微信选文件来自聊天记录、支付宝本地单选等,属于**平台能力限制**。
+
+---
+
+## 常见问题
+
+**Q:外部打开时,其它 props 还能用吗?**  
+能。`show-trigger="false"` / `headless` 只影响 UI,上传、限制、多选、事件等全部照常。
+
+**Q:组件必须和按钮放在一起吗?**  
+不必。组件放页面任意位置(甚至视觉上藏起来),按钮在别处调 `ref.choose()` 即可。注意不要把组件 `v-if` 掉,否则 ref 为空。
+
+**Q:为什么没有多选?**  
+确认 `multiple` 为 `true`(默认)且 `maxCount > 1`。支付宝本地文件、部分安卓系统选择器可能仍只能单选,可多次点选凑满。
+
+**Q:App 真机图片只有「图」字、不能预览?**  
+App 无本地 path 时用缩略图 + 内置全屏预览。需重新编译运行。
+
+**Q:如何判断都传完了?**  
+```js
+const allDone = files.value.every(
+  (f) => f.status === 'success' || f.status === 'fail'
+)
+const allOk = files.value.length && files.value.every((f) => f.status === 'success')
+```
+
+**Q:控制台一直刷「uni统计 上报超时」?**  
+与本组件无关。在项目 `manifest.json` 中设置:
+
+```json
+"uniStatistics": { "enable": false }
+```
+
+然后重新编译。
+
+---
+
+## 注意事项
+
+1. **仅 Vue3** + `uni_modules` easycom。  
+2. **`formData` 的值必须是字符串**(uni 规范),如 `folderId: '123'`。  
+3. **自动上传**需同时:`autoUpload === true` 且 `uploadUrl` 非空。  
+4. **`mode="headless"`** 不渲染列表/按钮,请自行 `v-model` + `ref` 方法。  
+5. **Android/iOS App 用 vue 页**(renderjs),不要用 nvue;**鸿蒙**用 `uni.chooseFile`,无需 renderjs。  
+6. 删除:内置按钮走 `beforeRemove` + `@remove`;headless 下自己改数组时不会走 `beforeRemove`,需业务自行确认。  
+7. 各端对「任意文件」支持程度不同,以真机为准;`formats` 为空时组件不做类型拦截。
+
+---
+
+## 版本
+
+当前能力见同目录 [changelog.md](./changelog.md)。

+ 2 - 0
uni_modules/ss-download/changelog.md

@@ -0,0 +1,2 @@
+## 1.0.0(2023-04-21)
+1.uni-app移动端-H5-小程序下载保存图片,文档和视频到手机,带进度条

+ 282 - 0
uni_modules/ss-download/components/ss-download/ss-download.vue

@@ -0,0 +1,282 @@
+<template>
+	<view></view>
+</template>
+
+<script>
+	export default {
+		name: 'ss-download',
+		props: {
+			fileUrl: {
+				type: String,
+				default: ''
+			},
+			fileType: {
+				type: String,
+				default: '', //1.预览图片,2.预览文件,3.预览视频
+			},
+		},
+		data() {
+			return {};
+		},
+		methods: {
+			toDownload(item) {
+				// #ifdef H5
+				this.downloadH5(item.src,item.fileName)
+				// #endif
+				// #ifdef MP-WEIXIN
+				if(item.type == 1){
+					this.saveToPhotosAlbum(item.src)
+				} else if (item.type == 2) {
+					this.saveFile(item.src)	
+				}else{
+					this.getDownVideo(item.src)
+				}
+				// #endif
+				// #ifdef APP-PLUS
+				this.saveFileToApp(item.src)
+				// #endif
+			},
+			//H5下载图片到本地
+			downloadH5(url,fileName) {
+				uni.downloadFile({
+					url: url, //仅为示例,并非真实的资源
+					success: (res) => {
+						console.log(res)
+						if (res.statusCode === 200) {
+							console.log('下载成功');
+							var oA = document.createElement("a");
+							oA.download = fileName || ''; // 设置下载的文件名,默认是'下载'
+							oA.href = res.tempFilePath; //临时路径再保存到本地
+							document.body.appendChild(oA);
+							oA.click();
+							oA.remove(); // 下载之后把创建的元素删除
+						}
+					}
+				});
+			},
+			//微信小程序保存文档,不支持h5
+			saveFile(url) {
+				uni.showLoading()
+				uni.downloadFile({ //下载文件资源到本地,返回文件的本地临时路径
+					url: url, //网络图片路径
+					success: (res) => {
+						var filePath = res.tempFilePath;
+						//保存到本地
+						uni.saveFile({
+							tempFilePath: filePath,
+							success: function(res) {
+								//res.savedFilePath文件的保存路径
+								uni.openDocument({
+									filePath: res.savedFilePath,
+									fileType: 'pdf',
+									showMenu: true,
+									success: function(res) {
+										uni.hideLoading()
+									},
+									fail: function(err) {
+										uni.hideLoading()
+									}
+								});
+							},
+							fail(err) {
+								console.log(err)
+								uni.hideLoading()
+								console.log('保存失败')
+							}
+						});
+					}
+				})
+			},
+			//微信小程序保存视频
+			getDownVideo(url) {
+				// 自定义 文件名称
+				uni.showLoading({
+					mask: true,
+					title: '下载中...'
+				})
+				let fileName = new Date().valueOf();
+				const task = uni.downloadFile({
+					url: url,
+					filePath: wx.env.USER_DATA_PATH + '/' + fileName + '.mp4', //  拼接本地文件路径
+					success: (res) => {
+						let filePath = res.filePath
+						uni.saveVideoToPhotosAlbum({
+							filePath,
+							success: (res) => {
+								uni.showToast({
+									title: '下载成功',
+									icon: 'success',
+								})
+								let fileMgr = wx.getFileSystemManager();
+								// 删除本地文件
+								fileMgr.unlink({
+									filePath: wx.env.USER_DATA_PATH + '/' + fileName +
+										'.mp4',
+									success: function(r) {
+										console.log('unlink-getFileSystemManager')
+										console.log(r)
+									},
+								})
+							},
+							fail(err) {
+								uni.showToast({
+									title: '保存失败',
+									icon: 'none',
+								})
+							},
+							complete(res) {
+								console.log('saveVideoToPhotosAlbum-complete')
+								console.log(res)
+								uni.hideLoading()
+							}
+						})
+					},
+					fail(err) {
+						uni.showToast({
+							title: '下载失败,请稍后再试',
+							icon: 'none',
+						})
+					},
+					complete(res) {
+						console.log('downloadFiledownloadFiledownloadFiledownloadFiledownloadFile')
+						console.log(res)
+					}
+				})
+				task.onProgressUpdate(this.onProgress)
+			},
+			// 提示下载进度
+			onProgress(res) {
+				uni.showLoading({
+					mask: true,
+					title: res.progress ? '下载中' + res.progress + "%" : '下载中...'
+				})
+			},
+			//微信小程序保存图片(此处也可用于app保存图片或视频)
+			saveToPhotosAlbum(url) {
+				// #ifdef APP-PLUS
+				var showLoading = plus.nativeUI.showWaiting("正在下载"); //创建一个showWaiting对象 
+				// #endif
+				let task = uni.downloadFile({
+					url: url,
+					success: res => {
+						const {
+							statusCode,
+							tempFilePath
+						} = res
+						if (statusCode === 200) { // saveImageToPhotosAlbum  saveVideoToPhotosAlbum  
+							uni.saveImageToPhotosAlbum({ //此处也可用saveVideoToPhotosAlbum
+								filePath: tempFilePath,
+								success: data => {
+									console.log('data----------------->', data)
+									uni.showToast({
+										title: '下载成功,文件已保存到' + data.path,
+										icon: 'success',
+									})
+								},
+								complete: (msg) => {
+									// #ifdef APP-PLUS
+									plus.nativeUI.closeWaiting()
+									// #endif
+								}
+							});
+						} else {
+							uni.showToast({
+								title: '下载失败',
+								icon: 'none',
+							})
+							// #ifdef APP-PLUS
+							plus.nativeUI.closeWaiting()
+							// #endif
+						}
+					},
+					complete: () => {
+
+					}
+				})
+				task.onProgressUpdate((res => {
+					// console.log('上传进度' + res.progress);
+					// console.log('已经上传的数据长度' + res.totalBytesSent);
+					// console.log('预期需要上传的数据总长度' + res.totalBytesExpectedToSend);
+					showLoading.setTitle("  正在下载" + res.progress + "%  ");
+				}))
+			},
+			//移动端下载文件
+			saveFileToApp(url){
+				var dtask = plus.downloader.createDownload(url, {},
+					function(d, status) {
+						uni.showToast({
+							title: '下载完成',
+							mask: false,
+							duration: 1000
+						});
+						//console.log(dtask);
+						// 下载完成
+						console.log('status: ' + status);
+						if (status == 200) {
+							console.log('下载成功:' + d.filename);
+							console.log('plus.io.convertLocalFileSystemURL(d.filename): ' + plus.io
+								.convertLocalFileSystemURL(d
+									.filename))
+							plus.runtime.openFile(plus.io.convertLocalFileSystemURL(d.filename), {}, function(success) {
+								console.log('打开成功')
+							}, function(error) {
+								console.log('打开失败')
+							})
+						} else {
+							uni.showToast({
+								title: '下载失败-02',
+								mask: false,
+								duration: 1500
+							});
+						}
+					});
+				try {
+					dtask.start(); // 开启下载的任务
+					var prg = 0;
+					var showLoading = plus.nativeUI.showWaiting("正在下载"); //创建一个showWaiting对象 
+					dtask.addEventListener('statechanged', function(task, status) {
+						// 给下载任务设置一个监听 并根据状态  做操作
+						switch (task.state) {
+							case 1:
+								showLoading.setTitle("正在下载");
+								break;
+							case 2:
+								showLoading.setTitle("已连接到服务器");
+								break;
+							case 3:
+								prg = parseInt((parseFloat(task.downloadedSize) / parseFloat(task.totalSize)) * 100);
+								showLoading.setTitle("  正在下载" + prg + "%  ");
+								break;
+							case 4:
+								plus.nativeUI.closeWaiting();
+								//下载完成
+								break;
+						}
+					});
+				} catch (err) {
+					plus.nativeUI.closeWaiting();
+					uni.showToast({
+						title: '更新失败-03',
+						mask: false,
+						duration: 1500
+					});
+				}
+			},
+
+		},
+		// watch: {
+		// 	fileUrl: {
+		// 		deep: true,
+		// 		handler: function(value) {
+		// 			if(this.fileUrl){
+		// 				this.toDownload({
+		// 					type:this.fileType,
+		// 					src:this.fileUrl,
+		// 				})
+		// 			}	
+		// 		}
+		// 	}
+		// }
+	};
+</script>
+

+ 82 - 0
uni_modules/ss-download/package.json

@@ -0,0 +1,82 @@
+{
+  "id": "ss-download",
+  "displayName": "移动端-H5-小程序下载保存图片,文档和视频到手机",
+  "version": "1.0.0",
+  "description": "uni-app移动端-H5-小程序下载保存图片,文档和视频到手机,带进度条",
+  "keywords": [
+    "移动端",
+    "H5",
+    "微信小程序",
+    "下载保存图片,文档和视频,带进度条"
+],
+  "repository": "",
+"engines": {
+  },
+  "dcloudext": {
+    "category": [
+        "前端组件",
+        "通用组件"
+    ],
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": ""
+  },
+  "uni_modules": {
+    "dependencies": [],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "y",
+        "aliyun": "y"
+      },
+      "client": {
+        "Vue": {
+          "vue2": "y",
+          "vue3": "y"
+        },
+        "App": {
+          "app-vue": "y",
+          "app-nvue": "y"
+        },
+        "H5-mobile": {
+          "Safari": "y",
+          "Android Browser": "y",
+          "微信浏览器(Android)": "y",
+          "QQ浏览器(Android)": "y"
+        },
+        "H5-pc": {
+          "Chrome": "y",
+          "IE": "y",
+          "Edge": "u",
+          "Firefox": "u",
+          "Safari": "u"
+        },
+        "小程序": {
+          "微信": "y",
+          "阿里": "u",
+          "百度": "u",
+          "字节跳动": "u",
+          "QQ": "u"
+        },
+        "快应用": {
+          "华为": "u",
+          "联盟": "u"
+        }
+      }
+    }
+  }
+}

+ 12 - 0
uni_modules/ss-download/readme.md

@@ -0,0 +1,12 @@
+#ss-download
+
+### 下载保存图片,文档,视频到手机,兼容H5端,微信小程序端,APP端,	带进度条
+ 
+### 不清楚使用方式可点击右侧导入示例项目运行完整示例
+
+## 使用说明
+| 属性		| 是否必填	|  值类型	| 默认值	| 说明			|
+| --------- | -------- 	| -----: 	| --: 	| :------------:|
+| fileUrl	|	是		| String	|空	    | 预览单个文档或视频传递url		|
+| fileType	|	是 		| String	|空	    | 类型(1.预览图片,2.预览文件,3.预览视频)		|
+