
環(huán)境uni-app 3.x Vue3Vite 5HBuilderX 打包 App。覆蓋 Android / iOS / H5 三條選文件路徑。對象src/utils/outsourceExcel.js662 行導出了 10 個函數(shù)。場景外購單開單頁點「導入」用戶選一個.xls/.xlsx上傳給后端解析成商品明細行工作臺的「文件上傳」走同一套選擇器但不限類型——圖片、PDF、Office 文檔、zip 壓縮包都能傳。頁面上只有一個「導入」按鈕組件薄得幾乎沒有邏輯真正的活全在這個 662 行的工具文件里。它要解決三件事把文件從系統(tǒng)里選出來、把五花八門的路徑壓成一種形態(tài)、把文件送到uploadFile能認的位置。下面按文件的組織順序逐段拆。一、文件全景10 個導出三類職責函數(shù)職責誰在用chooseOutsourceExcelFile選文件只認 Excel內(nèi)部按平臺分三套實現(xiàn)導入按鈕prepareOutsourceExcelFile上傳前準備App 端落沙盒選完文件后調(diào)chooseAnyFile選文件不限類型同一套實現(xiàn)換參數(shù)工作臺「文件上傳」prepareAnyFile任意文件的落沙盒保留原后綴工作臺「文件上傳」copyTempFileToNamedPath復制到_doc/并返回本地 URL被上面幾個復用normalizeAppPickedFile路徑歸一把五種形態(tài)壓成{path,name}內(nèi)部未導出buildSupplierListFromQuotedtl后端返回的扁平行 → 頁面要的分組結(jié)構(gòu)導入成功后調(diào)getFileNameFromPath從路徑里取文件名并解碼上面幾個共用getOutsourceFileExt/isOutsourceExcelName取擴展名 / 判斷是不是 Excel導入按鈕做前置校驗getAnyFileExt取任意擴展名最多 10 位任意文件上傳主線就一條選擇 → 歸一 → 交付。buildSupplierListFromQuotedtl屬于「回來之后」的事放文末單說。二、入口一個函數(shù)三套實現(xiàn)對外只暴露一個chooseOutsourceExcelFile內(nèi)部用條件編譯分流exportconstchooseOutsourceExcelFile(){// #ifdef APP-PLUSreturnwithTimeout(chooseAppExcelFile(選擇外購單模板,.xls,.xlsx,.xlsx),5*60*1000,選擇文件超時請重試);// #endif// #ifdef H5returnnewPromise((resolve,reject){/* uni.chooseFile見第六節(jié) */});// #endifreturnPromise.reject(newError(當前平臺暫不支持導入 Excel 文件));};兩個容易忽略的點條件編譯塊里必須return不能靠變量賦值否則打包后某個平臺會落空末尾那句Promise.reject是兜底保證在沒命中的平臺上調(diào)用方拿到的是一個明確的 Error而不是undefined.then is not a function。注意這三個參數(shù)不是寫死的第十一節(jié)就是靠傳空串把它們關(guān)掉的。三、Android自己起一個 Intentuni.chooseFile的type: file在 App 端沒有實現(xiàn)所以這一段只能寫原生constmainplus.android.runtimeMainActivity();constIntentplus.android.importClass(android.content.Intent);constintentnewIntent(Intent.ACTION_GET_CONTENT);intent.setType(*/*);intent.addCategory(Intent.CATEGORY_OPENABLE);constPICK_CODE20307;constpreviousmain.onActivityResult;main.onActivityResultfunction(requestCode,resultCode,data){if(requestCode!PICK_CODE)returnprevious?.(requestCode,resultCode,data);if(resultCode!-1)returnreject(newError(cancel));// RESULT_OK 恒為 -1...};main.startActivityForResult(intent,PICK_CODE);plus.android.startActivityForResult文檔里沒有、回調(diào)也不觸發(fā)唯一能拿到結(jié)果的辦法就是重寫Activity.onActivityResult。這段里的數(shù)字都是硬編碼RESULT_OK -1、requestCode自選 20307改代碼時別把它們當普通 magic number。四、content:// 的三級讀取Android 10 之后分區(qū)存儲生效_data列經(jīng)常為空能拿到的只是content://而uploadFile只認文件路徑。于是有了這個函數(shù)constresolvermain.getContentResolver();plus.android.importClass(resolver);constinputresolver.openInputStream(uri);constFileplus.android.importClass(java.io.File);constFileOutputStreamplus.android.importClass(java.io.FileOutputStream);constoutnewFileOutputStream(newFile(main.getCacheDir(),outsource_${Date.now()}.xlsx));letcopiedfalse;try{plus.android.importClass(android.os.FileUtils).copy(input,out);copiedtrue;}catch(e){}if(!copied){try{input.transferTo(out);copiedtrue;}catch(e){}}if(!copied){/* 8KB 字節(jié)流循環(huán) write */}降級層級手段備注一層FileUtils.copy最快但部分 ROM 沒有這個方法二層InputStream.transferToAPI 26低版本直接拋三層8KBbyte[]循環(huán)最慢但一定能跑函數(shù)末尾三件事不能省file.length() 0判空0 字節(jié)文件上傳后后端只會說「模板無數(shù)據(jù)」排查方向全歪、finally關(guān)流、失敗時返回空串而不是拋錯——上層統(tǒng)一彈「無法讀取所選文件」比把 Java 異常甩給用戶好。五、iOS兩個回調(diào)都可能是成功if(typeofplus.io.chooseFile!function){returnreject(newError(當前設(shè)備暫不支持系統(tǒng)文件選擇));}plus.io.chooseFile({title:選擇外購單模板,multiple:false,filter:.xls,.xlsx},(res){constppickPathFromPlusFileResult(res);p?resolve(p):reject(newError(未選擇文件));},(err){if(/cancel|取消/i.test(String(err?.message||err?.errMsg||err)))returnreject(newError(cancel));constppickPathFromPlusFileResult(err);// 某些版本把結(jié)果塞在 fail 里p?resolve(p):reject(newError(err?.message||選擇文件失敗));});plus.io.chooseFile不在官方文檔里所以先做存在性判斷filter是逗號分隔的字符串不是數(shù)組部分系統(tǒng)版本會忽略fail回調(diào)里也要嘗試解析一次結(jié)果這個寫法看著別扭但在實際機型上確實遇到過「選完文件卻走了 fail」。而且這個filter只是「建議」實測在 iOS 上照樣能選到.docx、.pptx、.zip選完一樣會進沙盒、一樣能傳。所以「能選」和「能傳」是兩件事客戶端這層限制只能算引導——第十一節(jié)那個「什么都能傳」的按鈕就是靠傳空filter做出來的。六、H5最短的一條分支uni.chooseFile返回blob:路徑瀏覽器環(huán)境可以直接交給uploadFile所以 H5 分支不需要落沙盒代碼只有十幾行。唯一要記住的是blob:的生命周期只在當前頁面刷新或跳轉(zhuǎn)就失效想復用必須先上傳換真實 URL。七、normalizeAppPickedFile把五種路徑壓成一種選完之后不直接上傳先過一層歸一。這一步是整個文件里分支最多的函數(shù)拿到的形態(tài)處理方式content://...先resolveAndroidContentPath再走內(nèi)容流復制/storage/...絕對路徑直接復制到沙盒file://...復制失敗時原樣返回能直接用路徑?jīng)]擴展名按.xlsx兜底復制失敗copy → toLocalURL(5s) → file://拼接三級降級if(/^content:\/\//i.test(path)){pathresolveAndroidContentPath(path)||copyAndroidContentUriToCache(path,name);if(!path)thrownewError(無法讀取所選文件請先將文件保存到手機存儲后重試);}constextgetExt(name)||getExt(path)||defaultExt;// Excel 傳 .xlsx任意文件傳 constcopiedawaitcopyTempFileToNamedPath(path,outsource_${Date.now()}${ext});return{path:copied,name:name||外購單${ext}};注意兩點入?yún)⒑头祷刂道锏膒ath同名不同義入?yún)⒖赡苁?URI返回值一定是沙盒里的file://地址getExt和defaultExt都是參數(shù)不是寫死的常量所以同一個函數(shù)換個傳參就成了「任意文件」版本。八、copyTempFileToNamedPath為什么要落_doc/plus.io.resolveLocalFileSystemURL(tempFilePath,(entry){plus.io.resolveLocalFileSystemURL(_doc/,(root){root.getDirectory(outsource_import/${Date.now()},{create:true},(dir){entry.copyTo(dir,fileName,(copied)resolve(copied.toLocalURL()),reject);},reject);},reject);},reject);三個原因content://不能直接上傳、臨時文件隨時被系統(tǒng)清理、復制后uploadFile才認。放_doc/私有目錄而非_downloads/是因為前者不需要存儲權(quán)限目錄按Date.now()分避免重名覆蓋用toLocalURL()而不是toURL()后者在部分機型返回的是不可讀的file:///storage/...。九、withTimeout三段超時不是隨便定的constwithTimeout(promise,ms,message)newPromise((resolve,reject){consttimersetTimeout(()reject(newError(message)),ms);Promise.resolve(promise).then((v){clearTimeout(timer);resolve(v);},(e){clearTimeout(timer);reject(e);});});環(huán)節(jié)超時依據(jù)等用戶選文件5 分鐘人工操作找文件慢是常態(tài)prepare復制/歸一15 秒本地 IO超過就是卡住了上傳解析120 秒后端要讀表、校驗、回數(shù)據(jù)把選文件也設(shè)成 15 秒是最常見的錯誤——用戶還在文件管理器里翻目錄這邊已經(jīng)判超時了。十、三個小工具撐起了所有邊界判斷exportconstgetFileNameFromPath(path){constcleanString(path||).split(?)[0].split(#)[0];constnameclean.split(/[\\/]/).pop()||;try{returndecodeURIComponent(name);}catch(e){returnname;}};exportconstgetOutsourceFileExt(nameOrPath)(String(nameOrPath||).match(/\.(xls|xlsx)$/i)||[])[0].toLowerCase();exportconstisOutsourceExcelName(name)/\.(xls|xlsx)$/i.test(String(name||));decodeURIComponent必須包try/catch文件名里出現(xiàn)裸%會拋URIError。getOutsourceFileExt返回帶點的擴展名拼文件名用isOutsourceExcelName只做布爾判斷前置校驗用兩個別混用。但這層校驗只能算「客氣地勸一下」別當成安全邊界它攔得住「有擴展名且不是 xls/xlsx」的文件卻攔不住 iOS 上選出來的.zip沒走這套判斷或者壓根沒擴展名。真正的類型判斷放在服務(wù)端——讀文件頭也好、按 MIME 也好至少不會把壓縮包當表格解析。十一、同一個文件的另一半工作臺的「文件上傳」工作臺那個藍色云朵按鈕沒有自己的頁面點下去走的是同一套選擇器。它現(xiàn)在的定位很明確任意類型文件上傳——圖片jpg/png、PDF、Word/PPT、Excel、zip/rar 壓縮包都走這一個入口前端不做任何擴展名過濾。// pages/workbench/index.vue{ignorePermission:true,type:fileUpload,text:文件上傳,icon:cloud-upload}consthandleActionClick(item){if(item?.typefileUpload)returnuploadAnyFile();// 沒有 path只能特判if(!item?.path)return;navigateTo(item.path);};constuploadAnyFileasync(){if(uploadingFile.value)return;// 防連點constfileawaitchooseAnyFile();// 選constpreparedawaitprepareAnyFile(file);// 落沙盒constdataawaituploadCommonFile(prepared.path);// POST /common/uploaduni.showToast({title:上傳成功,icon:success});};和導入按鈕相比區(qū)別只是三個參數(shù)參數(shù)Excel 導入任意文件上傳filter.xls,.xlsx空串iOS 不設(shè)過濾器defaultExt.xlsx沒后綴就不補getExtgetOutsourceFileExtgetAnyFileExt\.[a-z0-9]{1,10}$chooseExcelFileInIOS里寫的是if (filter) options.filter filter傳空串時系統(tǒng)選擇器就不加任何類型限制——圖片、.pdf、.docx、.pptx、.xlsx、.zip、.rar全都能選Android 那邊本來就是setType(*/*)。同一份代碼靠參數(shù)決定是「只收 Excel」還是「什么都收」這也是「filter 只能算引導」的由來。三個值得記一筆的細節(jié)沙盒目錄仍叫outsource_import/時間戳/從導入功能繼承來的看日志容易誤解沒有擴展名的文件會被復制成outsource_時間戳無后綴后端只能靠內(nèi)容猜類型uploadCommonFile里formData: { id: 1 }是后端強制字段通用上傳沒有單據(jù)可關(guān)聯(lián)這個 id 其實不承載語義。另外提醒一句這個入口是ignorePermission: true任何登錄用戶都能往服務(wù)器寫文件上傳成功只彈一句 toastaccessUrl拿到就丟了用戶既不知道文件進了哪個目錄也拿不到鏈接。要長期用至少得補「復制鏈接」和大小限制。十二、交棒準備完只做一件事返回{ path, name }之后文件的使命就結(jié)束剩下交給接口層uni.uploadFile打/saleOrder/importOutsourceExcelname: file、timeout: 120000、頭里帶 Bearer。響應(yīng)兼容「信封」和「裸對象」兩種結(jié)構(gòu)判定順序是 HTTP 2xx → 有無業(yè)務(wù)信封 → 載荷里有沒有rowList失敗記得reject(new Error(msg))傳對象會讓上層拿不到文案。十三、回來之后buildSupplierListFromQuotedtl后端返回的是扁平行頁面要「供應(yīng)商 → 明細行」兩層結(jié)構(gòu)。核心就一句分組鍵是spid 供應(yīng)商名。constspKey(r)${r.spid??r.sp_id??}${String(r.spname??r.spName??).trim()};constkspKey(raw);if(!byKey.has(k))byKey.set(k,{cpny:raw.spname,id:raw.spid,cpal:raw.spcpal,fetchList:[]});三件事要在這個循環(huán)里補齊poamt為空時用采購價 × 數(shù)量算不然合計列是空的、img_ids轉(zhuǎn)逗號串、actualSize/file_url統(tǒng)一成[{url}]——頁面組件只認這兩種形態(tài)。十四、復用這份文件的四條約定一個入口函數(shù) 條件編譯平臺差異全部封在文件里上層只認{ path, name }一切路徑先歸一凡是不以file://結(jié)尾的都當成不可信失敗返回明確狀態(tài)或Error不把原生異常拋給業(yè)務(wù)層超時按環(huán)節(jié)分級人工操作給足時間IO 和接口才收緊。按這四條寫這套能力復用到采購單、調(diào)撥單只要換個接口地址和字段映射。最后補一句題外話選擇器和擴展名校驗都只是「引導用戶選對」iOS 上 docx、ppt、zip 一樣能選、能傳。真要限制類型去服務(wù)端加判斷別指望客戶端這一層。