diff --git a/uniapp/youjiala/src/components/app-page/app-page.vue b/uniapp/youjiala/src/components/app-page/app-page.vue index ee61821..aa47752 100644 --- a/uniapp/youjiala/src/components/app-page/app-page.vue +++ b/uniapp/youjiala/src/components/app-page/app-page.vue @@ -1,4 +1,5 @@ + + + + diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json index 638a973..7fba8c2 100644 --- a/uniapp/youjiala/src/locale/lang/en-US.json +++ b/uniapp/youjiala/src/locale/lang/en-US.json @@ -650,6 +650,10 @@ "downloadDoneApp": "Saved to Phone storage/Download/有家啦", "downloading": "Downloading…", "previewNoImage": "No previewable images", + "previewOriginal": "HD", + "previewOriginalLoading": "Loading…", + "previewOriginalLoaded": "HD loaded", + "previewOriginalFail": "Failed to load HD", "loadingMedia": "Preparing media…", "pleaseWait": "Working…", "actionFailed": "Operation failed", diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json index 359882f..f0170d5 100644 --- a/uniapp/youjiala/src/locale/lang/zh-CN.json +++ b/uniapp/youjiala/src/locale/lang/zh-CN.json @@ -650,6 +650,10 @@ "downloadDoneApp": "已保存到 手机存储/Download/有家啦", "downloading": "下载中…", "previewNoImage": "没有可预览的图片", + "previewOriginal": "高清", + "previewOriginalLoading": "加载中…", + "previewOriginalLoaded": "已高清", + "previewOriginalFail": "高清图加载失败", "loadingMedia": "媒体准备中…", "pleaseWait": "处理中…", "actionFailed": "操作失败", diff --git a/uniapp/youjiala/src/pages/tab/file.vue b/uniapp/youjiala/src/pages/tab/file.vue index ac735d5..38ff4a9 100644 --- a/uniapp/youjiala/src/pages/tab/file.vue +++ b/uniapp/youjiala/src/pages/tab/file.vue @@ -16,6 +16,7 @@ */ import type { FolderRow } from "@/components/folder-move-picker/folder-move-picker.vue"; import type { ShareTarget } from "@/components/folder-share-picker/folder-share-picker.vue"; +import type { ImageViewerItem } from "@/components/image-viewer/image-viewer.vue"; import type { PickerOption } from "@/components/option-picker/option-picker.vue"; import type { FileCategory, FileItem, FileKind, FileTabItem, QuickEntryItem } from "@/data/custom/fileBrowser"; import { useLocale } from "uview-pro"; @@ -35,7 +36,7 @@ import publishVideoService from "@/service/master/publishVideoService"; import { useMediaIntentStore } from "@/stores/mediaIntent"; import { useUploadQueueStore } from "@/stores/uploadQueue"; import { chooseFiles } from "@/utils/chooseFiles"; -import { fetchFileBytes, saveFileBytes, toPlayableUrl } from "@/utils/dmsBytes"; +import { fetchFileBytes, saveFileBytes, toImageUrl, toPlayableUrl } from "@/utils/dmsBytes"; import { clearPlayback, setPlayback } from "@/utils/playbackBus"; import { transferGlyph } from "@/utils/transferGlyph"; @@ -593,27 +594,37 @@ async function actionDownload(targets: FileItem[]): Promise { }, t("FilePage.downloading")); } +// ----- 图片查看器(缩略图秒开 + 按需原图,见 components/image-viewer)----- +const viewerVisible = ref(false); +const viewerItems = ref([]); + /** 图片预览:单选一张、多选多图相册连览。 - * 走原图拼整通道(旧 MyComputer @preview-image → GetOrgPictureFile 就是分片拼整); - * DownloadFilePreview 是旧缩略图通道,依赖上传时生成的 scale 目录,本地库没有缩略图只会 400 */ -async function actionPreview(targets: FileItem[]): Promise { + * 缩略图先行——thumbUrl 列表已加载过(甚至命中 HTTP 缓存),组装进查看器秒开; + * 原图按需拉取(查看器底部「查看原图」触发 loadFull,走分片拼整 + 落盘旧通道)。 + * 替代旧 uni.previewImage 链路:它要先串行拉全部原图字节落盘才弹窗,慢的根因在此 */ +function actionPreview(targets: FileItem[]): void { const images = targets.filter(f => f.kind === "image"); if (!images.length) { toast(t("FilePage.previewNoImage")); return; } - await withBusy(async () => { - const urls: string[] = []; - for (const file of images) { - const record = recordOf(file); - if (record) { - urls.push(toPlayableUrl(await fetchFileBytes(record))); - } - } - if (urls.length) { - uni.previewImage({ urls, current: urls[0] }); - } + viewerItems.value = images.map((file) => { + const item: ImageViewerItem = { + name: file.name, + // 列表宫格同款 Scale 缩略图(后端拼好的直链);老文件无缩略图时留空,查看器会自动拉原图兜底 + thumb: file.thumbUrl || "", + loadFull: async () => { + const record = recordOf(file); + if (!record) { + throw new Error(t("FilePage.actionFailed")); + } + // App 端 WebView 不吃 file:// 本地文件,高清图走 data: URI(toImageUrl 内部分流) + return toImageUrl(await fetchFileBytes(record)); + }, + }; + return item; }); + viewerVisible.value = true; } /** 视频/音频播放:视频有直链 Url 直接进播放页,否则字节拼整后本地播(H5 blob: 地址经 query 传递) */ @@ -944,7 +955,7 @@ const transferIconWhite = transferGlyph("#ffffff"); + :show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible && !renameVisible && !moveVisible && !shareVisible && !publishVisible && !viewerVisible"> @@ -1023,6 +1034,8 @@ const transferIconWhite = transferGlyph("#ffffff"); + + diff --git a/uniapp/youjiala/src/utils/appFileWrite.ts b/uniapp/youjiala/src/utils/appFileWrite.ts index d11d159..5a4c6c9 100644 --- a/uniapp/youjiala/src/utils/appFileWrite.ts +++ b/uniapp/youjiala/src/utils/appFileWrite.ts @@ -19,8 +19,8 @@ declare const plus: any; /** 单次过桥字节数:512KB 转 base64 约 683KB 字符串,兼顾内存峰值与调用次数 */ const BYTES_PER_TRIP = 512 * 1024; -const BASE64_CHARS = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const BASE64_CHARS + = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /** * 纯 JS base64(浏览器 btoa 在 App 逻辑层不保证存在,不依赖它): @@ -41,6 +41,23 @@ function base64OfChunk(chunk: Uint8Array): string { return out; } +/** + * 整段字节转 base64(给 App 端图片 data: URI 用): + * 按 192KB(3 的倍数)分块独立编码再 join——块长恒为 3 的倍数时块尾不会产生 + * 多余 padding,几 MB 图片也不会因逐字符累加把主线程拖垮。 + */ +export function base64OfBytes(bytes: ArrayBuffer): string { + const data = new Uint8Array(bytes); + const chunkSize = 3 * 64 * 1024; + const parts: string[] = []; + for (let offset = 0; offset < data.length; offset += chunkSize) { + parts.push( + base64OfChunk(data.subarray(offset, Math.min(offset + chunkSize, data.length))), + ); + } + return parts.join(""); +} + /** 文件名里的路径分隔符与非法字符会让原生建不出文件,统一压平 */ function safeFileName(fileName: string): string { const clean = fileName.replace(/[\\/:*?"<>|]/g, "_").trim(); @@ -50,31 +67,31 @@ function safeFileName(fileName: string): string { /** 公共下载目录下的子文件夹:文件管理器 →「下载」里一眼能看到 */ const PUBLIC_DOWNLOAD_FOLDER = "有家啦"; -/** 常见扩展名的 MIME:MediaStore 靠它归类,未知按二进制流 */ -function mimeOf(fileName: string): string { +/** 常见扩展名的 MIME:MediaStore 靠它归类,H5 造 blob 时给 image 嗅探(Safari 不嗅探黑图),未知按二进制流 */ +export 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", + "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", + "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"; } @@ -300,9 +317,15 @@ function writeIos(bytes: ArrayBuffer, destPath: string): void { } // #endif +/** 预览/播放路径前缀:_doc 是 uni 官方本地路径格式,image/video/audio/openDocument/previewImage 全支持 */ +const PREVIEW_PATH_PREFIX = "_doc/files/"; + /** - * 把整段字节写成 _doc/files/,返回**绝对路径**: - * uni.openDocument / previewImage / video / audio 在 App 上都能直接吃这个路径。 + * 把整段字节写成 _doc/files/,返回本地相对路径 `_doc/files/`: + * 为什么不是绝对路径——uni 内置 image 组件(App 端 uni-app-view 的路径转换) + * 只把 /storage/、/sdcard/ 与 iOS 沙箱容器当文件系统路径,其余以 "/" 开头的 + * (应用私有目录 /data/user/0/...)会被拼到 _www 下变成 404,高清图因此全黑; + * "_doc/..." 会被统一转成 file:// 绝对路径,预览/播放/打开文档都吃。 * 非 App 端不会调用到这里,返回空串(TS 需要一条无条件返回)。 */ export function writeAppFileFromBytes( @@ -312,13 +335,14 @@ export function writeAppFileFromBytes( // #ifdef APP-PLUS const platform = String(plus.os?.name || "").toLowerCase(); const dir = platform === "android" ? androidFilesDir() : iosFilesDir(); - const destPath = `${dir}/${safeFileName(fileName)}`; + const safeName = safeFileName(fileName); + const destPath = `${dir}/${safeName}`; if (platform === "android") { writeAndroid(bytes, destPath); } else { writeIos(bytes, destPath); } - return destPath; + return `${PREVIEW_PATH_PREFIX}${safeName}`; // #endif // #ifndef APP-PLUS return ""; diff --git a/uniapp/youjiala/src/utils/dmsBytes.ts b/uniapp/youjiala/src/utils/dmsBytes.ts index cfc856b..bb0a945 100644 --- a/uniapp/youjiala/src/utils/dmsBytes.ts +++ b/uniapp/youjiala/src/utils/dmsBytes.ts @@ -5,14 +5,15 @@ * 分片号从 1 循环到总片数逐片 DownloadFileCombine,写进 (FileSize+1)*1024 的缓冲拼回整文件 * (FileSize 单位 KB,旧系统同款缓冲预分配)。 * 落盘/播放双出口:saveFileBytes 走拦截器 saveArrayBuffer;toPlayableUrl 给预览与播放页 - * (H5 是 blob: URL,小程序写本地用户目录,App 走 Native.js 二进制落盘)。 + * (H5 是带 MIME 的 blob: URL,小程序写本地用户目录,App 走 Native.js 落 _doc/files + * 后返回 "_doc/files/..." 本地相对路径——绝对路径会被 image 组件改写成 404 黑图)。 * 分片请求走 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"; +import { base64OfBytes, mimeOf, writeAppFileFromBytes } from "@/utils/appFileWrite"; /** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */ export interface FileBytes { @@ -20,14 +21,41 @@ export interface FileBytes { name: string; } +/** + * 按字节魔数嗅探图片 MIME:data: URI 必须带对类型才能解码, + * 老文件常丢扩展名(mimeOf 会回退 octet-stream,图片就打不开),用头几字节兜底 + */ +function imageMimeOf(bytes: ArrayBuffer, name: string): string { + const head = new Uint8Array(bytes, 0, Math.min(12, bytes.byteLength)); + if (head[0] === 0xFF && head[1] === 0xD8) { + return "image/jpeg"; + } + if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E) { + return "image/png"; + } + if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46) { + return "image/gif"; + } + if (head[0] === 0x42 && head[1] === 0x4D) { + return "image/bmp"; + } + if ( + head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 + && head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50 + ) { + return "image/webp"; + } + return mimeOf(name); +} + /** 分片取回并拼整(非视频通道,参数是 FindByKey 回的文件实体) */ async function fetchCombineBytes( orderKey: string, fileSizeKb: number, ): Promise { const segments = orderKey.split("-"); - const total = - Number.parseInt(segments[segments.length - 1].split(".")[0], 10) || 1; + const total + = Number.parseInt(segments[segments.length - 1].split(".")[0], 10) || 1; const merged = new Uint8Array((fileSizeKb + 1) * 1024); let offset = 0; // 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致 @@ -83,12 +111,14 @@ export async function saveFileBytes(file: FileBytes): Promise { /** * 生成可预览/可播放的地址: * H5 返回 blob: URL(用完交给调用方 revoke,播放页 onUnload 处理); - * 小程序写本地用户目录返回文件路径;App 用 Native.js 落 _doc/files 后返回绝对路径 - * (uni.getFileSystemManager 在 App 不存在,video/audio/previewImage 都吃绝对路径) + * 小程序写本地用户目录返回文件路径;App 落 _doc/files 后返回 "_doc/files/..." + * 本地相对路径(uni.getFileSystemManager 在 App 不存在,image/video/audio/ + * previewImage/openDocument 都认这个官方本地路径格式) */ export function toPlayableUrl(file: FileBytes): string { // #ifdef H5 - return URL.createObjectURL(new Blob([file.bytes])); + // Blob 必须带 MIME:Chrome 会嗅探无 type 的 blob,Safari/部分微信内核不嗅探,直接黑图 + return URL.createObjectURL(new Blob([file.bytes], { type: mimeOf(file.name) })); // #endif // #ifndef H5 // #ifdef APP-PLUS @@ -104,3 +134,19 @@ export function toPlayableUrl(file: FileBytes): string { // #endif // #endif } + +/** + * 生成给 image 组件直接显示的图片地址: + * App 端必须走 data: URI——WebView 对 file:// 本地文件有访问限制(实测私有目录 + * 绝对路径与 _doc 相对路径都加载失败、静默黑屏),而 data: 不受文件系统策略 + * 影响,image 组件内部(DATA_RE)也原样放行;体积膨胀约 1/3,单张按需触发、 + * 关闭查看器即释放,可接受。H5 blob / 小程序本地路径不受此影响,沿用原通道。 + */ +export function toImageUrl(file: FileBytes): string { + // #ifdef APP-PLUS + return `data:${imageMimeOf(file.bytes, file.name)};base64,${base64OfBytes(file.bytes)}`; + // #endif + // #ifndef APP-PLUS + return toPlayableUrl(file); + // #endif +} diff --git a/uniapp/youjiala/src/utils/rotateGlyph.ts b/uniapp/youjiala/src/utils/rotateGlyph.ts new file mode 100644 index 0000000..5ed9305 --- /dev/null +++ b/uniapp/youjiala/src/utils/rotateGlyph.ts @@ -0,0 +1,32 @@ +/** + * 图片查看器左旋/右旋自定义字形(内联 SVG,不依赖 iconfont 字体文件)。 + * uview-pro 图标库没有旋转语义(reload 是循环箭头、形不似),按 custom-icon.md + * 规范自绘:path 取自 Material Icons 的 rotate_left / rotate_right,原 fill 色 + * 改为按参数注入(查看器内恒白)。 + */ +/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */ +const ROTATE_LEFT_SEGMENTS = [ + "M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32z", + "M13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z", +]; + +const ROTATE_RIGHT_SEGMENTS = [ + "M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02z", + "M13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z", +]; + +/** 拼 data URI:fill 注入色值,viewBox 原样保留 Material 的 24 方形 */ +function glyphOf(segments: string[], color: string): string { + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +/** 向左(逆时针)旋转 */ +export function rotateLeftGlyph(color: string): string { + return glyphOf(ROTATE_LEFT_SEGMENTS, color); +} + +/** 向右(顺时针)旋转 */ +export function rotateRightGlyph(color: string): string { + return glyphOf(ROTATE_RIGHT_SEGMENTS, color); +}