void> = {
share: actionShare,
publish: actionPublish,
preview: actionPreview,
+ // 多选栏的「视频播放/音乐播放」:与单击条目一致,进影视厅/音乐厅并从该条开播;
+ // 面板(file-action-sheet)的同名动作仍走 play 单文件播放页
+ hall: targets => targets[0] && openInHall(targets[0]),
play: targets => targets[0] && actionPlay(targets[0]),
detail: targets => targets[0] && actionDetail(targets[0]),
};
diff --git a/uniapp/youjiala/src/static/icons/media-back.png b/uniapp/youjiala/src/static/icons/media-back.png
new file mode 100644
index 0000000..ea86865
Binary files /dev/null and b/uniapp/youjiala/src/static/icons/media-back.png differ
diff --git a/uniapp/youjiala/src/static/legal/privacy-policy.html b/uniapp/youjiala/src/static/legal/privacy-policy.html
new file mode 100644
index 0000000..fa1eecf
--- /dev/null
+++ b/uniapp/youjiala/src/static/legal/privacy-policy.html
@@ -0,0 +1,213 @@
+
+
+
+
+
+ 有家相册隐私政策
+
+
+
+ 有家相册隐私政策
+ 更新日期:2026年10月20日 | 生效日期:2026年10月20日
+
+
+ 「有家相册」(以下简称“本应用”)由有家相册开发团队(以下简称“我们”)开发并运营。我们深知个人信息对你的重要性,将按照《中华人民共和国个人信息保护法》《网络安全法》等法律法规的要求,采取相应的安全保护措施,尽力保护你的个人信息安全可控。
+
+
+
+ 请你在使用本应用前,仔细阅读并充分理解本政策。你点击弹窗中的“同意并接受”按钮,即表示你已阅读并同意本政策的全部内容。
+
+
+ 一、我们收集的信息
+ 我们仅在你使用具体功能时,按照“最小必要”原则收集以下信息:
+
+ -
+ 账号信息:注册与登录时你主动提供手机号并设置密码。密码经加密处理(前端摘要 +
+ RSA 加密)后传输与存储,我们不会以明文形式保存你的密码。
+
+ -
+ 个人资料:你可选填的头像、昵称。头像从相册选择或拍摄后上传至服务器。
+
+ -
+ 实名认证信息:当你主动发起身份认证时,我们会收集你拍摄或选择的身份证照片,仅用于核验你的真实身份,不会用于其他目的,也不会向无关第三方提供。
+
+ -
+ 家庭相册与族谱数据:你或家族管理员录入的家族成员姓名、称呼、生日、亲属关系,以及你主动上传的照片、视频、音频、文档等文件。上述内容仅在你的家族成员范围内按你设定的权限可见。
+
+ -
+ 设备与日志信息:为保障账号与数据安全、排查故障,我们会收集设备型号、操作系统版本、设备标识符(如
+ OAID、Android ID)、IP 地址、操作日志与崩溃日志。
+
+
+
+ 二、系统权限调用说明
+
+ 本应用仅在实现对应功能时申请以下权限,你可以随时在系统设置中关闭,关闭后将无法使用对应功能,但不影响其他功能的使用:
+
+
+ - 相机:拍摄头像、身份证照片或上传照片、视频。
+ -
+ 相册(读写):从相册选择图片视频上传;将下载的照片、视频保存到系统相册。
+
+ - 存储:缓存与保存你上传、下载的家庭共享文件。
+ -
+ 网络状态(WiFi/移动网络):判断网络连接状态,优化文件上传下载体验。
+
+ - 震动:操作反馈提示。
+
+
+ 三、我们如何使用信息
+
+ - 提供账号注册、登录与安全校验服务;
+ - 提供家庭相册、族谱管理、文件共享与成员权限管理服务;
+ - 在你发起实名认证时完成身份核验;
+ - 保障服务安全稳定运行,预防、发现和处置账号安全与数据安全风险;
+ - 在你同意的前提下,改进产品功能与使用体验。
+
+
+ 我们不会将收集的信息用于本政策未载明的其他用途;如需变更用途,将再次征得你的明示同意。
+
+
+ 四、信息的共享、转让与公开披露
+
+ -
+ 家族内共享:你上传的照片、文件及录入的族谱信息,会按家族管理员设定的权限在你的家族成员范围内共享。请你在上传前确认相关内容已获得涉及成员的同意。
+
+ -
+ 第三方 SDK:本应用基于 DCloud uni-app
+ 引擎开发。为提供统计分析、崩溃排查与持续优化服务,引擎会在运行过程中采集应用启动时间、异常错误日志及设备唯一识别码(如
+ Android ID/OAID)。相关信息详见《DCloud App
+ 引擎隐私政策》(https://dcloud.io/license/appprivacy.html)。我们已关闭独立的第三方统计服务。
+
+ -
+ 不做的事:我们不会向任何第三方出售你的个人信息;不会在未征得你同意的情形下向无关第三方共享、转让你的个人信息,法律法规另有规定的除外。
+
+
+
+ 五、信息的存储与保护
+
+ - 你的个人信息存储于中华人民共和国境内的服务器。
+ - 传输环节采用 HTTPS 加密,密码等敏感字段额外加密处理。
+ - 我们通过访问权限控制、数据备份等措施保护你的信息安全。
+ -
+ 存储期限:在你使用本应用期间持续保存;你删除相关内容或注销账号后,我们将在法律法规允许的期限内删除或匿名化处理。
+
+
+
+ 六、你的权利
+
+ -
+ 查阅与更正:你可以在应用内查看并修改你的资料与家庭信息。
+
+ -
+ 删除:你可以在应用内删除你上传的照片、文件等内容(回收站清空后不可恢复)。
+
+ -
+ 撤回同意:你可以在系统设置中关闭相关权限,或通过设置页修改隐私选项。
+
+ -
+ 注销账号:你可以联系管理员申请注销账号,注销后你的个人信息将被删除或匿名化处理。
+
+
+
+ 七、未成年人保护
+
+ 本应用主要面向成年人。若你是未满 14
+ 周岁的未成年人,请在监护人陪同下阅读本政策,并在取得监护人同意后使用本应用。我们不会主动收集未成年人的个人信息;如发现在未获监护人同意的情况下收集了未成年人的个人信息,我们会尽快删除相关数据。
+
+
+ 八、本政策的变更
+
+ 我们可能适时修订本政策。当收集信息的目的、方式、范围发生重大变化时,我们会通过应用内弹窗等显著方式再次征得你的同意。你可以在本页面随时查阅最新版本。
+
+
+ 九、联系我们
+
+ 如你对本政策或个人信息保护有任何疑问、意见或投诉,可通过应用内反馈渠道联系我们,或发送邮件至
+ 【待填写联系邮箱】。我们将在十五个工作日内予以回复。
+
+
+
diff --git a/uniapp/youjiala/src/static/legal/service-agreement.html b/uniapp/youjiala/src/static/legal/service-agreement.html
new file mode 100644
index 0000000..63fe4d8
--- /dev/null
+++ b/uniapp/youjiala/src/static/legal/service-agreement.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+ 有家相册服务协议
+
+
+
+ 有家相册服务协议
+ 更新日期:2026年10月20日 | 生效日期:2026年10月20日
+
+
+ 欢迎使用「有家相册」!本协议是你与有家相册开发团队(以下简称“我们”)之间就使用「有家相册」应用(以下简称“本应用”)所订立的协议。
+
+
+
+ 请你务必审慎阅读、充分理解本协议各条款,特别是免除或限制责任的条款。你点击弹窗中的“同意并接受”按钮,或实际使用本应用,即视为你已阅读并同意本协议的全部内容。如你不同意本协议,请停止使用本应用。
+
+
+ 一、协议的接受与变更
+
+ 我们可能根据业务发展或法律法规的变化适时修订本协议,修订后的协议将在应用内公布。若你在协议修订后继续使用本应用,即视为你接受修订后的协议;若你不同意修订内容,请停止使用本应用。
+
+
+ 二、账号注册与安全
+
+ -
+ 你需使用手机号注册账号,并妥善保管账号与密码。密码经加密处理,我们不掌握你的明文密码。
+
+ -
+ 账号仅限你本人使用,不得出借、转让或出售。因你保管不善导致的账号风险及损失,由你自行承担。
+
+ -
+ 如发现账号被盗用或存在安全隐患,请立即通过应用内渠道联系我们处理。
+
+
+
+ 三、服务内容
+
+ 本应用为你提供家庭相册、家族族谱管理、文件共享、成员与权限管理等服务。部分功能(如身份认证)需要额外完成实名核验后方可使用。我们有权根据运营需要调整服务内容,并将以适当方式告知你。
+
+
+ 四、用户行为规范
+
+ 你在使用本应用时,应遵守法律法规及公序良俗,不得利用本应用制作、上传、传播以下内容:
+
+
+ - 违反国家法律法规的内容;
+ - 侵害他人肖像权、名誉权、隐私权、知识产权等合法权益的内容;
+ - 含有病毒、木马等恶意程序的内容;
+ - 其他违反法律法规或本协议约定的内容。
+
+
+ 你上传包含其他家庭成员的照片、视频等内容的,应事先取得相关权利人的同意。因你违反上述规范引发的争议与责任,由你自行承担;给我们造成损失的,我们有权依法追偿。
+
+
+ 五、内容与知识产权
+
+ -
+ 你上传的照片、视频、文件及录入的家庭信息,其权利仍归你或相关权利人所有。
+
+ -
+ 为向你提供存储、展示、共享等本应用功能,你授予我们在服务范围内使用上述内容的必要许可(如存储、传输、在你的家族成员范围内按权限展示)。该许可仅限于提供服务之目的,不包含向无关第三方披露。
+
+ -
+ 本应用的软件、界面、图标、商标等知识产权归我们或相关权利人所有,未经许可不得复制、修改或用于商业目的。
+
+
+
+ 六、隐私保护
+
+ 我们重视你的个人信息保护,具体收集与使用规则请查阅《隐私政策》。该政策是本协议不可分割的组成部分。
+
+
+ 七、服务的变更、中断与终止
+
+ -
+ 因系统维护、升级或不可抗力等原因,服务可能出现短暂中断,我们将尽力提前告知并缩短中断时间。
+
+ -
+ 若你严重违反本协议或法律法规,我们有权视情节采取限制功能、暂停或终止服务的措施。
+
+ -
+ 你可以随时停止使用本应用并申请注销账号。账号注销后,我们将依照《隐私政策》处理你的个人信息。
+
+
+
+ 八、免责声明与责任限制
+
+ -
+ 因不可抗力、网络运营商故障、第三方服务异常等非我们可控因素导致的服务中断或数据损失,我们将尽力协助处理,但在法律允许的范围内不承担责任。
+
+ -
+ 请你自行对重要的照片、文件保留备份。对于因你误操作(如删除后清空回收站)造成的数据丢失,我们无法恢复。
+
+ -
+ 我们不对家族成员自行录入的信息的准确性、完整性作保证,相关信息的真实性由录入者负责。
+
+
+
+ 九、法律适用与争议解决
+
+ 本协议的订立、履行与解释均适用中华人民共和国法律。因本协议产生的争议,双方应友好协商解决;协商不成的,任何一方可向有管辖权的人民法院提起诉讼。
+
+
+ 十、联系我们
+
+ 如你对本协议有任何疑问、意见或建议,可通过应用内反馈渠道联系我们,或发送邮件至
+ 【待填写联系邮箱】。
+
+
+
diff --git a/uniapp/youjiala/src/stores/uploadQueue.ts b/uniapp/youjiala/src/stores/uploadQueue.ts
index 3be8345..d9c4986 100644
--- a/uniapp/youjiala/src/stores/uploadQueue.ts
+++ b/uniapp/youjiala/src/stores/uploadQueue.ts
@@ -7,6 +7,7 @@ import {
bearerAuth,
extractBusinessError,
} from "@/common/http.interceptor";
+import { copyIntoAppUploadDir } from "@/utils/appUploadFile";
/**
* 上传队列 store:任务状态的唯一持有者,传输列表页只是它的视图。
@@ -80,47 +81,15 @@ function resolveChunkSize(): number {
return (intranet ? 3 : 1) * 1024 * 1024;
}
-// #ifdef APP-PLUS
-/**
- * App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
- * uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先把文件复制成
- * _doc/uploads/uid-1-1.ext 再传;真机验证前不引入分片循环(读不了本地字节切片)。
- * 项目 types 未含 plus 声明,本地宽松兜底(仅 App 运行时真实存在)。
- */
-// eslint-disable-next-line ts/no-explicit-any
-declare const plus: any;
-// #endif
-
function prepareAppFilePath(item: UploadItem): Promise {
// #ifdef APP-PLUS
- const chunkName = `uni-${Date.now()}-1-1${extOf(item.name)}`;
- return new Promise((resolve, reject) => {
- plus.io.resolveLocalFileSystemURL(
- "_doc/",
- (root: any) => {
- root.getDirectory(
- "uploads",
- { create: true },
- (dir: any) => {
- plus.io.resolveLocalFileSystemURL(
- item.filePath,
- (entry: any) => {
- entry.copyTo(
- dir,
- chunkName,
- (moved: any) => resolve(moved.toLocalURL()),
- reject,
- );
- },
- reject,
- );
- },
- reject,
- );
- },
- reject,
- );
- });
+ // App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
+ // uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先复制成协议名再传;
+ // 真机验证前不引入分片循环(读不了本地字节切片)
+ return copyIntoAppUploadDir(
+ item.filePath,
+ `uni-${Date.now()}-1-1${extOf(item.name)}`,
+ );
// #endif
// #ifndef APP-PLUS
return Promise.resolve(item.filePath);
diff --git a/uniapp/youjiala/src/utils/appFileWrite.ts b/uniapp/youjiala/src/utils/appFileWrite.ts
new file mode 100644
index 0000000..d11d159
--- /dev/null
+++ b/uniapp/youjiala/src/utils/appFileWrite.ts
@@ -0,0 +1,364 @@
+/**
+ * App 端二进制落盘(下载保存 / 图片预览 / 音视频播放共用)
+ *
+ * 为什么需要:uni.getFileSystemManager 与 uni.chooseFile 一样仅小程序可用,
+ * App 端方法根本不存在,writeFile/writeFileSync 一调就抛 is not a function;
+ * 而 5+ 的 plus.io FileWriter.write 只接受字符串,写不了二进制字节,只能走 Native.js。
+ *
+ * 过桥策略:JS 侧按 512KB 切片转 base64,Android 交给 android.util.Base64.decode
+ * 得到 Java byte[] 再 FileOutputStream.write,iOS 交给 NSData +
+ * NSFileHandle.writeData —— 字节始终以 Java/ObjC 对象在原生侧流转,
+ * 不做逐字节的 plus.android.invoke,避免大文件把桥打爆。
+ */
+
+// #ifdef APP-PLUS
+// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
+declare const plus: any;
+// #endif
+
+/** 单次过桥字节数:512KB 转 base64 约 683KB 字符串,兼顾内存峰值与调用次数 */
+const BYTES_PER_TRIP = 512 * 1024;
+
+const BASE64_CHARS =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+/**
+ * 纯 JS base64(浏览器 btoa 在 App 逻辑层不保证存在,不依赖它):
+ * 只用来把一段字节变成原生侧能一次性解码的字符串。
+ */
+function base64OfChunk(chunk: Uint8Array): string {
+ let out = "";
+ const len = chunk.length;
+ for (let index = 0; index < len; index += 3) {
+ const b0 = chunk[index];
+ const b1 = index + 1 < len ? chunk[index + 1] : 0;
+ const b2 = index + 2 < len ? chunk[index + 2] : 0;
+ out += BASE64_CHARS[b0 >> 2];
+ out += BASE64_CHARS[((b0 & 3) << 4) | (b1 >> 4)];
+ out += index + 1 < len ? BASE64_CHARS[((b1 & 15) << 2) | (b2 >> 6)] : "=";
+ out += index + 2 < len ? BASE64_CHARS[b2 & 63] : "=";
+ }
+ return out;
+}
+
+/** 文件名里的路径分隔符与非法字符会让原生建不出文件,统一压平 */
+function safeFileName(fileName: string): string {
+ const clean = fileName.replace(/[\\/:*?"<>|]/g, "_").trim();
+ return clean || `file-${Date.now()}`;
+}
+
+/** 公共下载目录下的子文件夹:文件管理器 →「下载」里一眼能看到 */
+const PUBLIC_DOWNLOAD_FOLDER = "有家啦";
+
+/** 常见扩展名的 MIME:MediaStore 靠它归类,未知按二进制流 */
+function mimeOf(fileName: string): string {
+ const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
+ const map: Record = {
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ gif: "image/gif",
+ webp: "image/webp",
+ bmp: "image/bmp",
+ mp3: "audio/mpeg",
+ m4a: "audio/mp4",
+ wav: "audio/wav",
+ aac: "audio/aac",
+ mp4: "video/mp4",
+ mov: "video/quicktime",
+ "3gp": "video/3gpp",
+ pdf: "application/pdf",
+ txt: "text/plain",
+ json: "application/json",
+ xls: "application/vnd.ms-excel",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ doc: "application/msword",
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ zip: "application/zip",
+ };
+ return map[ext] || "application/octet-stream";
+}
+
+/** 下载文件名加时间戳:同名文件不互相覆盖,返回路径也可预测 */
+function uniqueFileName(fileName: string): string {
+ const now = new Date();
+ const p = (n: number) => String(n).padStart(2, "0");
+ const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
+ const dot = fileName.lastIndexOf(".");
+ if (dot <= 0) {
+ return `${fileName}-${stamp}`;
+ }
+ return `${fileName.slice(0, dot)}-${stamp}${fileName.slice(dot)}`;
+}
+
+// #ifdef APP-PLUS
+/** _doc/files 目录(不存在就建):下载/预览产物与上传用的 _doc/uploads 分开放 */
+function androidFilesDir(): string {
+ const root = plus.io.convertLocalFileSystemURL("_doc");
+ const dir = `${root}/files`;
+ const File = plus.android.importClass("java.io.File");
+ // File.mkdirs 是同步调用:建目录不必再绕 plus.io 的异步回调
+ plus.android.invoke(new File(dir), "mkdirs");
+ return dir;
+}
+
+/** 把整段字节按 512KB 分块写进任意 Java 输出流(本地文件 / MediaStore 都走它) */
+function writeChunks(bytes: ArrayBuffer, output: any): void {
+ const Base64 = plus.android.importClass("android.util.Base64");
+ const total = bytes.byteLength;
+ for (let offset = 0; offset < total; offset += BYTES_PER_TRIP) {
+ const chunk = new Uint8Array(
+ bytes,
+ offset,
+ Math.min(BYTES_PER_TRIP, total - offset),
+ );
+ // importClass 出来的对象要直接调:plus.android.invoke 对本重载会静默失败,
+ // 文件落成 0 字节且不抛异常
+ const raw = Base64.decode(base64OfChunk(chunk), 0);
+ output.write(raw);
+ }
+ output.flush();
+}
+
+/** Android:base64 → byte[] → FileOutputStream,按 512KB 分块顺序写入 */
+function writeAndroid(bytes: ArrayBuffer, destPath: string): void {
+ const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
+ const output = new FileOutputStream(destPath);
+ try {
+ writeChunks(bytes, output);
+ } finally {
+ output.close();
+ }
+}
+
+/** Android 版本号:29 = Android 10,MediaStore.Downloads 从这版才有 */
+function androidSdkInt(): number {
+ try {
+ const Build = plus.android.importClass("android.os.Build$VERSION");
+ const sdk = Number(Build.SDK_INT);
+ if (sdk > 0) {
+ return sdk;
+ }
+ } catch {
+ // 反射失败时按系统版本号估算
+ }
+ const major = Number.parseInt(String(plus.os?.version || ""), 10);
+ if (Number.isNaN(major)) {
+ return 0;
+ }
+ return major >= 10 ? 29 : 22;
+}
+
+/** 公共「下载」目录(/storage/emulated/0/Download) */
+function publicDownloadDir(): string {
+ const Environment = plus.android.importClass("android.os.Environment");
+ // 静态方法直接调:plus.android.invoke 传类对象不生效,会回 null
+ const dir = Environment.getExternalStoragePublicDirectory("Download");
+ if (!dir) {
+ throw new Error("public-download-dir-unavailable");
+ }
+ return String(plus.android.invoke(dir, "getAbsolutePath"));
+}
+
+/**
+ * Android 10+:写 MediaStore.Downloads,文件直接出现在「下载/有家啦」,
+ * 应用自建条目无需任何存储权限,用户在文件管理器里就能看到。
+ * 返回公共目录里的绝对路径(openDocument 用)。
+ */
+function writeAndroidPublic(bytes: ArrayBuffer, fileName: string): string {
+ const MediaStoreDownloads = plus.android.importClass(
+ "android.provider.MediaStore$Downloads",
+ );
+ const ContentValues = plus.android.importClass(
+ "android.content.ContentValues",
+ );
+ const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
+ const values = new ContentValues();
+ values.put("_display_name", fileName);
+ values.put("mime_type", mimeOf(fileName));
+ values.put("relative_path", `Download/${PUBLIC_DOWNLOAD_FOLDER}`);
+ // 写完前不让其它应用看到半截文件
+ values.put("is_pending", 1);
+ const resolver = plus.android.invoke(
+ plus.android.runtimeMainActivity(),
+ "getContentResolver",
+ );
+ const uri = plus.android.invoke(
+ resolver,
+ "insert",
+ MediaStoreDownloads.EXTERNAL_CONTENT_URI,
+ values,
+ );
+ if (!uri) {
+ throw new Error("mediastore-insert-failed");
+ }
+ const pfd = plus.android.invoke(resolver, "openFileDescriptor", uri, "w");
+ if (!pfd) {
+ throw new Error("mediastore-open-failed");
+ }
+ const output = new FileOutputStream(
+ plus.android.invoke(pfd, "getFileDescriptor"),
+ );
+ try {
+ writeChunks(bytes, output);
+ } catch (error) {
+ // 写失败:删掉刚插入的空条目,避免「下载」里留一个 0 字节文件
+ try {
+ plus.android.invoke(resolver, "delete", uri, null, null);
+ } catch {
+ // 删不掉也无所谓,交给上层回退
+ }
+ throw error;
+ } finally {
+ output.close();
+ plus.android.invoke(pfd, "close");
+ }
+ // 清掉 pending 标记,文件正式对外可见
+ const done = new ContentValues();
+ done.put("is_pending", 0);
+ plus.android.invoke(resolver, "update", uri, done, null, null);
+ return `${publicDownloadDir()}/${PUBLIC_DOWNLOAD_FOLDER}/${fileName}`;
+}
+
+/**
+ * Android 9 及以下:直接写公共「下载/有家啦」,需要 WRITE_EXTERNAL_STORAGE。
+ * 没授权时这里会抛异常,由 writeDownloadFile 回退私有目录。
+ */
+function writeAndroidLegacy(bytes: ArrayBuffer, fileName: string): string {
+ const dir = `${publicDownloadDir()}/${PUBLIC_DOWNLOAD_FOLDER}`;
+ const File = plus.android.importClass("java.io.File");
+ plus.android.invoke(new File(dir), "mkdirs");
+ const destPath = `${dir}/${fileName}`;
+ writeAndroid(bytes, destPath);
+ // 不通知扫描的话,系统「下载」视图(MediaStore 索引)里看不到新文件
+ try {
+ const MediaScannerConnection = plus.android.importClass(
+ "android.media.MediaScannerConnection",
+ );
+ MediaScannerConnection.scanFile(
+ plus.android.runtimeMainActivity(),
+ [destPath],
+ null,
+ null,
+ );
+ } catch {
+ // 扫描失败只是索引慢,文件本身已经写好了
+ }
+ return destPath;
+}
+
+/** iOS:_doc/files 目录(不存在就建) */
+function iosFilesDir(): string {
+ const root = plus.io.convertLocalFileSystemURL("_doc");
+ const dir = `${root}/files`;
+ const manager = plus.ios.invoke(
+ plus.ios.importClass("NSFileManager"),
+ "defaultManager",
+ );
+ plus.ios.invoke(
+ manager,
+ "createDirectoryAtPath:withIntermediateDirectories:attributes:error:",
+ dir,
+ true,
+ null,
+ null,
+ );
+ return dir;
+}
+
+/** iOS:base64 → NSData → NSFileHandle 顺序追加 */
+function writeIos(bytes: ArrayBuffer, destPath: string): void {
+ const NSData = plus.ios.importClass("NSData");
+ // 先落一个空文件:目标不存在时 fileHandleForWritingAtPath 回 nil,后续 writeData 全丢
+ plus.ios.invoke(
+ plus.ios.invoke(NSData, "dataWithBase64EncodedString:", ""),
+ "writeToFile:atomically:",
+ destPath,
+ false,
+ );
+ const handle = plus.ios.invoke(
+ plus.ios.importClass("NSFileHandle"),
+ "fileHandleForWritingAtPath:",
+ destPath,
+ );
+ const total = bytes.byteLength;
+ for (let offset = 0; offset < total; offset += BYTES_PER_TRIP) {
+ const chunk = new Uint8Array(
+ bytes,
+ offset,
+ Math.min(BYTES_PER_TRIP, total - offset),
+ );
+ const data = plus.ios.invoke(
+ NSData,
+ "dataWithBase64EncodedString:",
+ base64OfChunk(chunk),
+ );
+ plus.ios.invoke(handle, "writeData:", data);
+ }
+ plus.ios.invoke(handle, "synchronizeFile");
+ plus.ios.invoke(handle, "closeFile");
+}
+// #endif
+
+/**
+ * 把整段字节写成 _doc/files/,返回**绝对路径**:
+ * uni.openDocument / previewImage / video / audio 在 App 上都能直接吃这个路径。
+ * 非 App 端不会调用到这里,返回空串(TS 需要一条无条件返回)。
+ */
+export function writeAppFileFromBytes(
+ bytes: ArrayBuffer,
+ fileName: string,
+): string {
+ // #ifdef APP-PLUS
+ const platform = String(plus.os?.name || "").toLowerCase();
+ const dir = platform === "android" ? androidFilesDir() : iosFilesDir();
+ const destPath = `${dir}/${safeFileName(fileName)}`;
+ if (platform === "android") {
+ writeAndroid(bytes, destPath);
+ } else {
+ writeIos(bytes, destPath);
+ }
+ return destPath;
+ // #endif
+ // #ifndef APP-PLUS
+ return "";
+ // #endif
+}
+
+/** 下载落盘结果:path 给 openDocument,isPublic 决定提示文案 */
+export interface SavedFile {
+ path: string;
+ isPublic: boolean;
+}
+
+/**
+ * 下载落到用户能找到的地方:
+ * - Android 10+:写 MediaStore 公共「下载/有家啦」,免存储权限,文件管理器可见;
+ * - Android 9-:直接写同一个公共目录(需存储写权限);
+ * - 上面两条失败(无权限/厂商 ROM 限制)与 iOS:回退应用私有目录,
+ * 只能靠 openDocument 打开后「另存/分享」导出。
+ * 文件名自动加时间戳:重复下载不互相覆盖,返回路径也能直接用于打开。
+ */
+export function writeDownloadFile(
+ bytes: ArrayBuffer,
+ fileName: string,
+): SavedFile {
+ // #ifdef APP-PLUS
+ const target = uniqueFileName(safeFileName(fileName));
+ if (String(plus.os?.name || "").toLowerCase() === "android") {
+ try {
+ const path =
+ androidSdkInt() >= 29
+ ? writeAndroidPublic(bytes, target)
+ : writeAndroidLegacy(bytes, target);
+ return { path, isPublic: true };
+ } catch {
+ // 权限/MediaStore 异常:退回私有目录,至少文件不丢
+ }
+ }
+ return { path: writeAppFileFromBytes(bytes, target), isPublic: false };
+ // #endif
+ // #ifndef APP-PLUS
+ return { path: "", isPublic: false };
+ // #endif
+}
diff --git a/uniapp/youjiala/src/utils/appUploadFile.ts b/uniapp/youjiala/src/utils/appUploadFile.ts
new file mode 100644
index 0000000..35f9663
--- /dev/null
+++ b/uniapp/youjiala/src/utils/appUploadFile.ts
@@ -0,0 +1,58 @@
+/**
+ * App 端上传前落盘(头像 / 身份证 / 网盘文件共用)
+ *
+ * 后端 PackUploadFile / PackUploadDmsFile 对 multipart filename 做 Split('-') 取末两段
+ * 判定分片序号(协议名 uid-序号-总数.ext),而 uni.uploadFile 在 App 上只能拿 filePath 的
+ * basename 当 filename——chooseImage/选择器给的临时文件名没有三段结构,后端直接数组越界。
+ * 故先把文件按协议名复制进 _doc/uploads,再把新路径交给 uni.uploadFile:
+ * multipart filename 即 basename,天然满足协议;页面需要把物理名存库时(AvatarFilename 等)
+ * 传同一个 targetName 即可保持与后端落盘名一致。
+ */
+
+// #ifdef APP-PLUS
+// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
+// eslint-disable-next-line ts/no-explicit-any
+declare const plus: any;
+// #endif
+
+/**
+ * 把 App 本地文件复制进 _doc/uploads 并起目标物理名,回可上传的本地路径。
+ * 非 App 端不需要这一层,原样返回(调用方按 filePath 直传即可)。
+ */
+export function copyIntoAppUploadDir(
+ sourcePath: string,
+ targetName: string,
+): Promise {
+ // #ifdef APP-PLUS
+ return new Promise((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ "_doc/",
+ (root: any) => {
+ root.getDirectory(
+ "uploads",
+ { create: true },
+ (dir: any) => {
+ plus.io.resolveLocalFileSystemURL(
+ sourcePath,
+ (entry: any) => {
+ entry.copyTo(
+ dir,
+ targetName,
+ (copied: any) => resolve(copied.toLocalURL()),
+ reject,
+ );
+ },
+ reject,
+ );
+ },
+ reject,
+ );
+ },
+ reject,
+ );
+ });
+ // #endif
+ // #ifndef APP-PLUS
+ return Promise.resolve(sourcePath);
+ // #endif
+}
diff --git a/uniapp/youjiala/src/utils/chooseFiles.ts b/uniapp/youjiala/src/utils/chooseFiles.ts
index 4ec38db..ed9b307 100644
--- a/uniapp/youjiala/src/utils/chooseFiles.ts
+++ b/uniapp/youjiala/src/utils/chooseFiles.ts
@@ -1,16 +1,20 @@
/**
- * 选文件(H5 / App 共用入口)
+ * 选文件(H5 / 小程序 / App 共用入口)
*
- * uni.chooseFile 两端语义一致:H5 底层是 (tempFilePath 为 blob: URL,
- * tempFiles 是 File 对象),App 底层是原生选择器(tempFilePath 为本地路径)。
- * 差异只体现在拿到的 filePath 形态上——uni.uploadFile 两端都能吃,故上层无需分流。
+ * 平台能力差(官方平台差异表):
+ * - H5 / 微信小程序(及鸿蒙 4.31+):uni.chooseFile 原生可用,直接转发;
+ * - App(Android/iOS):uni.chooseFile 并不存在,调用即报 is not a function,
+ * Android 走 Native.js 的 ACTION_GET_CONTENT 系统文件选择器、iOS 走
+ * UIDocumentPickerViewController,均免原生插件(官方推荐路线)。
+ * 系统选择器回给的是 content:// 或沙盒 URL,而 5+ 的 uni.uploadFile / plus.io 只吃文件路径,
+ * 故 App 侧选中后一律先落到 _doc/uploads、再交出 file:// 路径。
* 用户取消选择按「空结果」处理而不是 reject,调用方不必为取消写 catch。
*/
export interface PickedFile {
/** 原始文件名(上传时作为 multipart form 字段名,后端按它落 OrgFileName) */
name: string;
- /** H5 为 blob: URL,App 为本地文件路径 */
+ /** H5 为 blob: URL,App/小程序为本地文件路径(file:// 或绝对路径) */
filePath: string;
/** 字节数,个别端拿不到时为 0 */
size: number;
@@ -18,18 +22,54 @@ export interface PickedFile {
raw?: unknown;
}
+export interface ChooseFileOptions {
+ /** 仅收指定后缀(如 [".fm"]):H5/小程序交给系统过滤;App 选择器不支持后缀过滤,选中后按文件名挡掉 */
+ extension?: string[];
+}
+
/** 从路径兜底推导文件名(部分端 tempFiles 缺 name) */
function nameFromPath(path: string, index: number): string {
const clean = path.split("?")[0];
const tail = clean.substring(clean.lastIndexOf("/") + 1);
- return tail || `file-${index + 1}`;
+ if (!tail) {
+ return `file-${index + 1}`;
+ }
+ try {
+ return decodeURIComponent(tail);
+ } catch {
+ return tail;
+ }
}
-export function chooseFiles(count = 10): Promise {
+/** 后缀(含点、小写):App 端按名过滤与落盘命名共用 */
+function extNameOf(fileName: string): string {
+ const dot = fileName.lastIndexOf(".");
+ return dot > 0 ? fileName.slice(dot).toLowerCase() : "";
+}
+
+export function chooseFiles(
+ count = 10,
+ options: ChooseFileOptions = {},
+): Promise {
+ // #ifdef APP-PLUS
+ // 鸿蒙 App 运行时(HBuilderX 4.31+)有 uni.chooseFile,传统 Android/iOS 没有,必须分流
+ if (typeof (uni as any).chooseFile !== "function") {
+ return chooseByNativePicker(count, options);
+ }
+ // #endif
+ return chooseByUniApi(count, options);
+}
+
+/** uni.chooseFile 通道:H5 底层是 ,小程序是 wx.chooseMessageFile */
+function chooseByUniApi(
+ count: number,
+ options: ChooseFileOptions,
+): Promise {
return new Promise((resolve, reject) => {
uni.chooseFile({
count,
type: "all",
+ ...(options.extension?.length ? { extension: options.extension } : {}),
success: (res) => {
// 声明为 string | string[](各端回传形态不一),统一收敛成数组
const raw = res.tempFilePaths as string | string[];
@@ -62,3 +102,379 @@ export function chooseFiles(count = 10): Promise {
});
});
}
+
+// #ifdef APP-PLUS
+// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
+// eslint-disable-next-line ts/no-explicit-any
+declare const plus: any;
+
+/** onActivityResult 里只认我们自己这一发 */
+const FILE_PICK_REQUEST = 31826;
+
+/** App 端统一入口:按平台走系统文件选择器,再按后缀过滤(系统选择器不吃 extension) */
+async function chooseByNativePicker(
+ count: number,
+ options: ChooseFileOptions,
+): Promise {
+ const platform = String(plus.os?.name || "").toLowerCase();
+ const picked =
+ platform === "android"
+ ? await pickAndroidFiles(count)
+ : await pickIosFiles(count);
+ const extension = (options.extension || []).map((item) => item.toLowerCase());
+ const files = extension.length
+ ? picked.filter((file) => extension.includes(extNameOf(file.name)))
+ : picked;
+ // 选中了文件但全被后缀挡掉:报错让调用方提示,而不是静默返回空(像用户没选)
+ if (!files.length && extension.length && picked.length) {
+ throw new Error(`unsupported-file-type:${extension.join("/")}`);
+ }
+ return files;
+}
+
+/** _doc/uploads 建好并回绝对路径:FileOutputStream 要求真实目录存在 */
+function ensureUploadDir(): Promise {
+ return new Promise((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ "_doc/",
+ (root: any) => {
+ root.getDirectory(
+ "uploads",
+ { create: true },
+ (dir: any) =>
+ resolve(plus.io.convertLocalFileSystemURL(dir.fullPath)),
+ reject,
+ );
+ },
+ reject,
+ );
+ });
+}
+
+/** plus.io 取本地文件大小:拿不到按 0(上传进度仍由 UploadTask 回报) */
+function statLocalFileSize(filePath: string): Promise {
+ return new Promise((resolve) => {
+ plus.io.resolveLocalFileSystemURL(
+ filePath,
+ (entry: any) => {
+ entry.getMetadata(
+ (meta: any) => resolve(Number(meta.size) || 0),
+ () => resolve(0),
+ );
+ },
+ () => resolve(0),
+ );
+ });
+}
+
+/** Android:ACTION_GET_CONTENT 系统文件选择器(官方推荐的 Native.js 路线,免原生插件) */
+function pickAndroidFiles(count: number): Promise {
+ return new Promise((resolve, reject) => {
+ const Intent = plus.android.importClass("android.content.Intent");
+ const intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ if (count > 1) {
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+ }
+ const main = plus.android.runtimeMainActivity();
+ let finished = false;
+ const finish = (settle: () => void): void => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ main.onActivityResult = null;
+ settle();
+ };
+ // 不要把 intent 套一层 Intent.createChooser:chooser 经 Native.js 回传后
+ // startActivityForResult 收不到结果,表现为点「+」毫无动静
+ main.startActivityForResult(intent, FILE_PICK_REQUEST);
+ // 钩子必须在 start 之后再挂:部分基座拉起新界面时会先回调一次空 onActivityResult,
+ // 提前挂会被它当作「取消」立即收口,用户真正选中后的结果再也进不来
+ main.onActivityResult = (
+ requestCode: number,
+ resultCode: number,
+ data: any,
+ ) => {
+ if (requestCode !== FILE_PICK_REQUEST || finished) {
+ return;
+ }
+ if (resultCode !== -1 || !data) {
+ finish(() => resolve([])); // RESULT_OK = -1,其余(取消/异常)按空结果收敛
+ return;
+ }
+ importAndroidResult(data).then(
+ (files) => finish(() => resolve(files)),
+ (error) => finish(() => reject(error)),
+ );
+ };
+ });
+}
+
+/** 多选走 ClipData,单选走 getData;两路都空按取消处理 */
+function collectAndroidUris(data: any): any[] {
+ const uris: any[] = [];
+ const clip = plus.android.invoke(data, "getClipData");
+ if (clip) {
+ const total = Number(plus.android.invoke(clip, "getItemCount")) || 0;
+ for (let index = 0; index < total; index += 1) {
+ const item = plus.android.invoke(clip, "getItemAt", index);
+ uris.push(plus.android.invoke(item, "getUri"));
+ }
+ }
+ if (!uris.length) {
+ const single = plus.android.invoke(data, "getData");
+ if (single) {
+ uris.push(single);
+ }
+ }
+ return uris;
+}
+
+/** 批量落盘:单个 provider 打不开(云端/加密盘常见)不拖垮整批 */
+async function importAndroidResult(data: any): Promise {
+ const uris = collectAndroidUris(data);
+ if (!uris.length) {
+ return [];
+ }
+ const resolver = plus.android.invoke(
+ plus.android.runtimeMainActivity(),
+ "getContentResolver",
+ );
+ const dirPath = await ensureUploadDir();
+ const files: PickedFile[] = [];
+ let lastError: unknown = null;
+ for (let index = 0; index < uris.length; index += 1) {
+ try {
+ files.push(await importAndroidUri(resolver, uris[index], dirPath, index));
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ if (!files.length && lastError) {
+ throw lastError;
+ }
+ return files;
+}
+
+/** 单个 content:// 落盘:取显示名 → 拷进 _doc/uploads → 回 file:// 路径与大小 */
+async function importAndroidUri(
+ resolver: any,
+ uri: any,
+ dirPath: string,
+ index: number,
+): Promise {
+ const displayName =
+ queryUriColumn(resolver, uri, "_display_name") || `file-${index + 1}`;
+ const destPath = `${dirPath}/pick-${Date.now()}-${index}${extNameOf(displayName)}`;
+ copyUriToLocalFile(resolver, uri, destPath);
+ const filePath = `file://${destPath}`;
+ return {
+ name: displayName,
+ filePath,
+ size: await statLocalFileSize(filePath),
+ };
+}
+
+/**
+ * content:// → 本地文件:ContentResolver 输入流 + FileChannel.transferFrom 直拷,
+ * 不在 JS 侧过字节(Java 层零拷贝,Native.js 也不需要 byte[] 循环)。
+ * resolver/input 都是 plus.android.invoke 反射取回的实例,身上没有挂 JS 方法代理,
+ * 直接调 resolver.openInputStream 会报 "is not a function",必须走 invoke;
+ * FileOutputStream 是 importClass 后 new 出来的,方法代理完整,才可直接调。
+ */
+function copyUriToLocalFile(resolver: any, uri: any, destPath: string): void {
+ const input = plus.android.invoke(resolver, "openInputStream", uri);
+ if (!input) {
+ throw new Error("file-provider-unreadable");
+ }
+ const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
+ const Channels = plus.android.importClass("java.nio.channels.Channels");
+ // FileChannel 也要先 importClass:getChannel() 的返回值才会挂上方法代理
+ plus.android.importClass("java.nio.channels.FileChannel");
+ const output = new FileOutputStream(destPath);
+ try {
+ const channelIn = Channels.newChannel(input);
+ const channelOut = output.getChannel();
+ // count 用 int 上限而不是 1e15:后者转 long 时会溢出,
+ // transferFrom 写 0 字节且不抛异常,落盘成空文件(后端报 Image cannot be loaded)
+ channelOut.transferFrom(channelIn, 0, 2147483647);
+ channelOut.close();
+ } finally {
+ try {
+ output.close();
+ } catch {
+ // 已关或写失败:以主流程异常为准
+ }
+ try {
+ plus.android.invoke(input, "close");
+ } catch {
+ // 同上
+ }
+ }
+}
+
+/** 读 ContentResolver 单列(_display_name 等):provider 不支持该列时给空串 */
+function queryUriColumn(resolver: any, uri: any, column: string): string {
+ let cursor: any = null;
+ try {
+ cursor = plus.android.invoke(
+ resolver,
+ "query",
+ uri,
+ [column],
+ null,
+ null,
+ null,
+ );
+ if (!cursor || !plus.android.invoke(cursor, "moveToFirst")) {
+ return "";
+ }
+ const index = Number(plus.android.invoke(cursor, "getColumnIndex", column));
+ if (index < 0) {
+ return "";
+ }
+ return String(plus.android.invoke(cursor, "getString", index) ?? "");
+ } catch {
+ return "";
+ } finally {
+ if (cursor) {
+ try {
+ plus.android.invoke(cursor, "close");
+ } catch {
+ // cursor 已关或 provider 异常:忽略
+ }
+ }
+ }
+}
+
+/** iOS:UIDocumentPickerViewController(inPlace=false:系统把文件拷进沙盒,回调 URL 可直接读) */
+function pickIosFiles(count: number): Promise {
+ return new Promise((resolve, reject) => {
+ let picker: any = null;
+ let delegate: any = null;
+ const cleanup = (): void => {
+ try {
+ plus.ios.deleteObject(picker);
+ } catch {
+ // 已释放
+ }
+ try {
+ plus.ios.deleteObject(delegate);
+ } catch {
+ // 已释放
+ }
+ };
+ const settle = (files: PickedFile[]): void => {
+ settleIosFiles(files).then((list) => {
+ cleanup();
+ resolve(list);
+ }, reject);
+ };
+ try {
+ picker = plus.ios.newObject("UIDocumentPickerViewController");
+ // public.data 覆盖全部文档类型(.fm 这类自定义后缀没有专属 UTI,只能放开后按名挡)
+ plus.ios.invoke(
+ picker,
+ "initWithDocumentTypes:inPlace:",
+ ["public.data"],
+ false,
+ );
+ delegate = plus.ios.implements("NSObject", {
+ "documentPicker:didPickDocumentsAtURLs:": (_picker: any, urls: any) => {
+ const total = Number(plus.ios.invoke(urls, "count")) || 0;
+ const files: PickedFile[] = [];
+ for (let index = 0; index < total; index += 1) {
+ const url = plus.ios.invoke(urls, "objectAtIndex:", index);
+ const path = String(plus.ios.invoke(url, "path") ?? "");
+ if (path) {
+ files.push({
+ name: nameFromPath(path, index),
+ filePath: `file://${path}`,
+ size: 0,
+ });
+ }
+ }
+ settle(files);
+ },
+ // iOS 11 之前的老选择器走单数回调,留着兜底
+ "documentPicker:didPickDocumentAtURL:": (_picker: any, url: any) => {
+ const path = String(plus.ios.invoke(url, "path") ?? "");
+ settle(
+ path
+ ? [
+ {
+ name: nameFromPath(path, 0),
+ filePath: `file://${path}`,
+ size: 0,
+ },
+ ]
+ : [],
+ );
+ },
+ "documentPickerWasCancelled:": () => {
+ cleanup();
+ resolve([]);
+ },
+ });
+ picker.plusSetAttribute("delegate", delegate);
+ picker.plusSetAttribute("allowsMultipleSelection", count > 1);
+ const controller = iosTopViewController();
+ plus.ios.invoke(
+ controller,
+ "presentViewController:animated:completion:",
+ picker,
+ true,
+ null,
+ );
+ } catch (error) {
+ cleanup();
+ reject(error);
+ }
+ });
+}
+
+/** 找到当前最上层的 ViewController:优先 keyWindow,回退当前 Webview 的根控制器 */
+function iosTopViewController(): any {
+ let controller: any = null;
+ try {
+ const application = plus.ios.invoke(
+ plus.ios.importClass("UIApplication"),
+ "sharedApplication",
+ );
+ controller = plus.ios.invoke(
+ plus.ios.invoke(application, "keyWindow"),
+ "rootViewController",
+ );
+ } catch {
+ controller = null;
+ }
+ if (!controller) {
+ const webview = plus.ios.currentWebview();
+ controller = plus.ios.invoke(
+ plus.ios.invoke(webview, "window"),
+ "rootViewController",
+ );
+ }
+ // 有弹层时挂到最上层,避免 present 被忽略
+ for (let depth = 0; controller && depth < 6; depth += 1) {
+ const presented = plus.ios.invoke(controller, "presentedViewController");
+ if (!presented) {
+ break;
+ }
+ controller = presented;
+ }
+ return controller;
+}
+
+/** 沙盒文件补真实大小(选择器回调只给路径) */
+async function settleIosFiles(files: PickedFile[]): Promise {
+ await Promise.all(
+ files.map(async (file) => {
+ file.size = await statLocalFileSize(file.filePath);
+ }),
+ );
+ return files;
+}
+// #endif
diff --git a/uniapp/youjiala/src/utils/dmsBytes.ts b/uniapp/youjiala/src/utils/dmsBytes.ts
index 35f2344..cfc856b 100644
--- a/uniapp/youjiala/src/utils/dmsBytes.ts
+++ b/uniapp/youjiala/src/utils/dmsBytes.ts
@@ -5,11 +5,14 @@
* 分片号从 1 循环到总片数逐片 DownloadFileCombine,写进 (FileSize+1)*1024 的缓冲拼回整文件
* (FileSize 单位 KB,旧系统同款缓冲预分配)。
* 落盘/播放双出口:saveFileBytes 走拦截器 saveArrayBuffer;toPlayableUrl 给预览与播放页
- * (H5 是 blob: URL,小程序/App 写本地用户目录返回文件路径)。
+ * (H5 是 blob: URL,小程序写本地用户目录,App 走 Native.js 二进制落盘)。
+ * 分片请求走 requestBuffer 而不是 dmsFileService.DownloadFileCombine:后者会把文件名
+ * 再当 body 发一份(后端参数校验因此回「无效数据。」),且拼 URL 时没做 encodeURIComponent。
*/
import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor";
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
import dmsFileService from "@/service/master/dmsFileService";
+import { writeAppFileFromBytes } from "@/utils/appFileWrite";
/** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */
export interface FileBytes {
@@ -30,9 +33,16 @@ async function fetchCombineBytes(
// 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致
for (let index = 1; index <= total; index += 1) {
segments[segments.length - 2] = String(index);
- const chunk = await dmsFileService.DownloadFileCombine(segments.join("-"));
- merged.set(new Uint8Array(chunk as ArrayBuffer), offset);
- offset += (chunk as ArrayBuffer).byteLength;
+ // 裸 arraybuffer 通道:文件名含中文/空格必须编码,且后端报错时回的是 JSON,
+ // requestBuffer 会按 content-type 探测转成错误文案,不会把错误页当文件字节
+ const chunk = await requestBuffer({
+ url: `/DmsFile/DownloadFileCombine?fileName=${encodeURIComponent(
+ segments.join("-"),
+ )}`,
+ method: "POST",
+ });
+ merged.set(new Uint8Array(chunk), offset);
+ offset += chunk.byteLength;
}
// 缓冲按 (FileSize+1)KB 预分配可能比真实字节多几字节,按实际写入长度截断
return merged.slice(0, offset).buffer as ArrayBuffer;
@@ -73,24 +83,24 @@ export async function saveFileBytes(file: FileBytes): Promise {
/**
* 生成可预览/可播放的地址:
* H5 返回 blob: URL(用完交给调用方 revoke,播放页 onUnload 处理);
- * 小程序/App 写本地用户目录返回文件路径(video/audio/image 组件都吃)
+ * 小程序写本地用户目录返回文件路径;App 用 Native.js 落 _doc/files 后返回绝对路径
+ * (uni.getFileSystemManager 在 App 不存在,video/audio/previewImage 都吃绝对路径)
*/
export function toPlayableUrl(file: FileBytes): string {
// #ifdef H5
return URL.createObjectURL(new Blob([file.bytes]));
// #endif
// #ifndef H5
- let userDataPath = "";
- // #ifdef MP-WEIXIN
- userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
- // #endif
// #ifdef APP-PLUS
- userDataPath = "_doc";
+ return writeAppFileFromBytes(file.bytes, file.name);
// #endif
+ // #ifndef APP-PLUS
+ const userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
const filePath = `${userDataPath}/${file.name}`;
uni
.getFileSystemManager()
.writeFileSync(filePath, file.bytes, undefined as any);
return filePath;
// #endif
+ // #endif
}
diff --git a/uniapp/youjiala/src/utils/familyTreeExchange.ts b/uniapp/youjiala/src/utils/familyTreeExchange.ts
index 307629f..cf91ad0 100644
--- a/uniapp/youjiala/src/utils/familyTreeExchange.ts
+++ b/uniapp/youjiala/src/utils/familyTreeExchange.ts
@@ -61,6 +61,45 @@ function computeMac(
return CryptoJS.HmacSHA256(salt.clone().concat(iv).concat(ct), macKey);
}
+/** 随机字节:crypto-js 的 WordArray.random 在 App 逻辑层不可用 */
+/** (它去找 Node 原生 crypto,取不到就抛 Native crypto module could not be used…) */
+function randomWordArray(byteLength: number): WordArray {
+ interface WebCrypto {
+ getRandomValues?: (array: Uint32Array) => Uint32Array;
+ }
+ const webCrypto = (globalThis as { crypto?: WebCrypto }).crypto;
+ if (webCrypto && typeof webCrypto.getRandomValues === "function") {
+ const words = new Uint32Array(byteLength / 4);
+ webCrypto.getRandomValues(words);
+ return CryptoJS.lib.WordArray.create(Array.from(words), byteLength);
+ }
+ // #ifdef APP-PLUS
+ if (String(plus.os.name).toLowerCase() === "android") {
+ try {
+ type SecureRandomCtor = new () => { nextInt: () => number };
+ const SecureRandom = plus.android.importClass(
+ "java.security.SecureRandom",
+ ) as unknown as SecureRandomCtor;
+ const secure = new SecureRandom();
+ const words: number[] = [];
+ for (let i = 0; i < byteLength / 4; i += 1) {
+ // nextInt 返回 Java 有符号 int,>>> 0 转无符号位模式
+ words.push(secure.nextInt() >>> 0);
+ }
+ return CryptoJS.lib.WordArray.create(words, byteLength);
+ } catch {
+ // 落到下面的兰底
+ }
+ }
+ // #endif
+ // 兰底:没有系统安全随机源时用 Math.random(盐/IV 只需唯一,不承载明文机密)
+ const words: number[] = [];
+ for (let i = 0; i < byteLength / 4; i += 1) {
+ words.push((Math.random() * 0x100000000) >>> 0);
+ }
+ return CryptoJS.lib.WordArray.create(words, byteLength);
+}
+
/**
* 明文 JSON 文本 → .fm 信封文本。
* 盐与 IV 每次随机:同一谱同一密码两次导出密文也不同,不泄露任何相等关系。
@@ -70,8 +109,8 @@ export function encryptEnvelope(
password: string,
factor: string,
): string {
- const salt = CryptoJS.lib.WordArray.random(16);
- const iv = CryptoJS.lib.WordArray.random(16);
+ const salt = randomWordArray(16);
+ const iv = randomWordArray(16);
const { encKey, macKey } = deriveKeys(password, factor, salt);
const encrypted = CryptoJS.AES.encrypt(
CryptoJS.enc.Utf8.parse(plainText),
@@ -142,12 +181,87 @@ export function decryptEnvelope(
return plainText;
}
-/** UTF-8 字符串 → ArrayBuffer(.fm 落盘/上传字节;H5 与 App webview 均有 TextEncoder) */
+/**
+ * UTF-8 字符串 → ArrayBuffer(.fm 落盘/上传字节)。
+ * 手写编解码:App 逻辑层没有 TextEncoder/TextDecoder(会报 is not defined),
+ * 且必须支持 4 字节码点,否则 emoji/生僻字人名会被截断。
+ */
export function stringToBytes(text: string): ArrayBuffer {
- return new TextEncoder().encode(text).buffer as ArrayBuffer;
+ const buffer = new Uint8Array(text.length * 4);
+ let offset = 0;
+ for (let i = 0; i < text.length; i += 1) {
+ let code = text.charCodeAt(i);
+ // 代理对:合回完整码点
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
+ const next = text.charCodeAt(i + 1);
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
+ i += 1;
+ }
+ }
+ if (code < 0x80) {
+ buffer[offset] = code;
+ offset += 1;
+ } else if (code < 0x800) {
+ buffer[offset] = 0xc0 | (code >> 6);
+ buffer[offset + 1] = 0x80 | (code & 0x3f);
+ offset += 2;
+ } else if (code < 0x10000) {
+ buffer[offset] = 0xe0 | (code >> 12);
+ buffer[offset + 1] = 0x80 | ((code >> 6) & 0x3f);
+ buffer[offset + 2] = 0x80 | (code & 0x3f);
+ offset += 3;
+ } else {
+ buffer[offset] = 0xf0 | (code >> 18);
+ buffer[offset + 1] = 0x80 | ((code >> 12) & 0x3f);
+ buffer[offset + 2] = 0x80 | ((code >> 6) & 0x3f);
+ buffer[offset + 3] = 0x80 | (code & 0x3f);
+ offset += 4;
+ }
+ }
+ return buffer.buffer.slice(0, offset);
}
-/** ArrayBuffer → UTF-8 字符串(读回 .fm 文本) */
+/** ArrayBuffer → UTF-8 字符串(读回 .fm 文本;分块拼接避免大文件卡顿) */
export function bytesToString(bytes: ArrayBuffer): string {
- return new TextDecoder().decode(bytes);
+ const view = new Uint8Array(bytes);
+ const parts: string[] = [];
+ let chunk = "";
+ for (let i = 0; i < view.length; ) {
+ const lead = view[i];
+ let code: number;
+ let size: number;
+ if (lead < 0x80) {
+ code = lead;
+ size = 1;
+ } else if ((lead & 0xe0) === 0xc0) {
+ code = lead & 0x1f;
+ size = 2;
+ } else if ((lead & 0xf0) === 0xe0) {
+ code = lead & 0x0f;
+ size = 3;
+ } else {
+ code = lead & 0x07;
+ size = 4;
+ }
+ for (let j = 1; j < size && i + j < view.length; j += 1) {
+ code = (code << 6) | (view[i + j] & 0x3f);
+ }
+ i += size;
+ if (code > 0xffff) {
+ const point = code - 0x10000;
+ chunk += String.fromCharCode(
+ 0xd800 + (point >> 10),
+ 0xdc00 + (point & 0x3ff),
+ );
+ } else {
+ chunk += String.fromCharCode(code);
+ }
+ if (chunk.length >= 8192) {
+ parts.push(chunk);
+ chunk = "";
+ }
+ }
+ parts.push(chunk);
+ return parts.join("");
}