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 @@
+
+
+
+
+
+
+ {{ index + 1 }} / {{ slots.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ buttonText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("FilePage.previewOriginalLoading") }}
+
+
+
+
+
+
+
+
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);
+}