From 1ff69f3cb3e2d1429697c0433f80ea631e714ece Mon Sep 17 00:00:00 2001 From: owenchen Date: Wed, 21 Oct 2026 09:39:47 +0800 Subject: [PATCH] ow --- .../components/image-viewer/image-viewer.vue | 498 ++++++++++-------- uniapp/youjiala/src/stores/uploadQueue.ts | 26 +- uniapp/youjiala/src/utils/appFileWrite.ts | 48 +- uniapp/youjiala/src/utils/dmsBytes.ts | 30 +- 4 files changed, 349 insertions(+), 253 deletions(-) diff --git a/uniapp/youjiala/src/components/image-viewer/image-viewer.vue b/uniapp/youjiala/src/components/image-viewer/image-viewer.vue index 753a508..5c61d0e 100644 --- a/uniapp/youjiala/src/components/image-viewer/image-viewer.vue +++ b/uniapp/youjiala/src/components/image-viewer/image-viewer.vue @@ -14,38 +14,43 @@ import { computed, ref, watch } from "vue"; import { rotateLeftGlyph, rotateRightGlyph } from "@/utils/rotateGlyph"; export interface ImageViewerItem { - /** 列表缩略图直链(列表已加载过,秒显);空则打开时自动拉原图 */ - thumb?: string; - /** 原图按需加载器:页面注入,返回可播放地址(App 落盘路径 / H5 blob / 小程序本地路径) */ - loadFull?: () => Promise; - /** 展示名(顶部信息位) */ - name?: string; + /** 列表缩略图直链(列表已加载过,秒显);空则打开时自动拉原图 */ + thumb?: string; + /** 原图按需加载器:页面注入,返回可播放地址(App 落盘路径 / H5 blob / 小程序本地路径) */ + loadFull?: () => Promise; + /** 展示名(顶部信息位) */ + name?: string; } interface Slot { - /** 当前显示地址(缩略图或已加载的原图) */ - src: string; - /** 已加载的原图地址(未拉取为 undefined) */ - full?: string; - /** 原图拉取在途标记 */ - loading: boolean; - /** 当前图旋转角(左旋 -90 / 右旋 +90 累积,模 360) */ - rotate: number; - /** 当前图缩放倍率(放大 ×1.25 / 缩小 ÷1.25,0.6~4 之间) */ - scale: number; + /** 当前显示地址(缩略图或已加载的原图) */ + src: string; + /** 已加载的原图地址(未拉取为 undefined) */ + full?: string; + /** 原图拉取在途标记 */ + loading: boolean; + /** 当前图旋转角(左旋 -90 / 右旋 +90 累积,模 360) */ + rotate: number; + /** 当前图缩放倍率(放大 ×1.25 / 缩小 ÷1.25,0.6~4 之间) */ + scale: number; + /** 平移量(px,屏幕轴;只有放大后单指拖动才会变,缩回 1 倍自动归零) */ + x: number; + y: number; + /** 拖动会话中:临时关掉 transform 过渡,图片跟手 */ + dragging: boolean; } const props = withDefaults( - defineProps<{ - visible: boolean; - items?: ImageViewerItem[]; - }>(), - { items: () => [] }, + defineProps<{ + visible: boolean; + items?: ImageViewerItem[]; + }>(), + { items: () => [] }, ); const emit = defineEmits<{ - /** 点关闭按钮 */ - (e: "close"): void; + /** 点关闭按钮 */ + (e: "close"): void; }>(); // 旋转字形恒白,模块级生成一次(custom-icon.md:颜色固定 → 模块级 const) @@ -54,109 +59,169 @@ const rotateRightIcon = rotateRightGlyph("#ffffff"); const { t } = useLocale(); +// 图区尺寸缓存:拖动边界按「放大溢出量的一半」算,不必每帧调同步 API +const { windowWidth: viewWidth, windowHeight: viewHeight } = uni.getSystemInfoSync(); + const slots = ref([]); const index = ref(0); watch( - () => props.visible, - (v) => { - if (v) { - index.value = 0; - slots.value = (props.items ?? []).map(item => ({ - src: item.thumb || "", - full: undefined, - loading: false, - rotate: 0, - scale: 1, - })); - // 没有缩略图的老文件:打开即自动拉原图兜底,不留白等用户点按钮 - slots.value.forEach((_, i) => { - if (!slots.value[i].src) { - loadOriginal(i); + () => props.visible, + (v) => { + if (v) { + index.value = 0; + slots.value = (props.items ?? []).map(item => ({ + src: item.thumb || "", + full: undefined, + loading: false, + rotate: 0, + scale: 1, + x: 0, + y: 0, + dragging: false, + })); + // 没有缩略图的老文件:打开即自动拉原图兜底,不留白等用户点按钮 + slots.value.forEach((_, i) => { + if (!slots.value[i].src) { + loadOriginal(i); + } + }); } - }); - } - else { - revokeBlobs(); - // 关窗即释放高清地址:App 端是 data: URI(一张图几 MB),多图连览不能积压 - slots.value = []; - } - }, + else { + revokeBlobs(); + // 关窗即释放高清地址:App 端是 data: URI(一张图几 MB),多图连览不能积压 + slots.value = []; + } + }, ); /** 关闭时回收 H5 blob: 地址;App/小程序是本地文件路径,不处理(App 落盘文件正好当下次缓存) */ function revokeBlobs(): void { - // #ifdef H5 - slots.value.forEach((slot) => { - if (slot.full && slot.full.startsWith("blob:")) { - URL.revokeObjectURL(slot.full); - } - }); - // #endif + // #ifdef H5 + slots.value.forEach((slot) => { + if (slot.full && slot.full.startsWith("blob:")) { + URL.revokeObjectURL(slot.full); + } + }); + // #endif } function onSwipeChange(e: any): void { - index.value = e?.detail?.current ?? 0; + index.value = e?.detail?.current ?? 0; } /** 左旋/右旋:每图独立累积角,模 360 防无限增长 */ function rotateCurrent(delta: number): void { - const slot = slots.value[index.value]; - if (!slot) { - return; - } - slot.rotate = (slot.rotate + delta) % 360; + const slot = slots.value[index.value]; + if (!slot) { + return; + } + slot.rotate = (slot.rotate + delta) % 360; } -/** 放大/缩小:倍率夹在 0.6~4,超出范围按钮照点不生效(变换仍平滑) */ +/** + * 拖动边界:放大 n 倍后中心最多偏出 (n-1)/2 个屏宽/屏高,图片不会整体拖出视野; + * 缩回 1 倍时 max 为 0,偏移自动归零 + */ +function clampOffset(slot: Slot): void { + const maxX = Math.max(0, (slot.scale - 1) * viewWidth / 2); + const maxY = Math.max(0, (slot.scale - 1) * viewHeight / 2); + slot.x = Math.min(maxX, Math.max(-maxX, slot.x)); + slot.y = Math.min(maxY, Math.max(-maxY, slot.y)); +} + +// 单指拖动会话(同一时刻只有一根手指在拖当前图,不必存进每个 Slot) +let dragStartX = 0; +let dragStartY = 0; +let dragOriginX = 0; +let dragOriginY = 0; + +/** 放大后才启用单指平移:小图状态单指手势留给 swiper 翻页 */ +function onImageTouchStart(e: any): void { + const slot = slots.value[index.value]; + const touch = e.touches?.[0]; + if (!slot || !touch || slot.scale <= 1) { + return; + } + slot.dragging = true; + dragStartX = touch.clientX; + dragStartY = touch.clientY; + dragOriginX = slot.x; + dragOriginY = slot.y; +} + +/** 拖动平移:transform 里 translate 在最外层,位移沿屏幕轴、与旋转角无关,始终跟手 */ +function onImageTouchMove(e: any): void { + const slot = slots.value[index.value]; + const touch = e.touches?.[0]; + if (!slot || !slot.dragging || !touch) { + return; + } + slot.x = dragOriginX + touch.clientX - dragStartX; + slot.y = dragOriginY + touch.clientY - dragStartY; + clampOffset(slot); +} + +function onImageTouchEnd(): void { + const slot = slots.value[index.value]; + if (slot) { + slot.dragging = false; + } +} + +/** 放大(>1 倍)时禁用 swiper 翻页,把单指手势让给图片平移 */ +const panEnabled = computed(() => (slots.value[index.value]?.scale ?? 1) > 1); + +/** 放大/缩小:倍率夹在 0.6~4,超出范围按钮照点不生效(变换仍平滑);缩放后收一下平移量 */ function zoomCurrent(factor: number): void { - const slot = slots.value[index.value]; - if (!slot) { - return; - } - slot.scale = Math.min(4, Math.max(0.6, slot.scale * factor)); + const slot = slots.value[index.value]; + if (!slot) { + return; + } + slot.scale = Math.min(4, Math.max(0.6, slot.scale * factor)); + clampOffset(slot); } /** 高清钮文案:拉取中 / 已高清 / 高清 */ const buttonText = computed(() => { - const slot = slots.value[index.value]; - if (!slot) { - return t("FilePage.previewOriginal"); - } - if (slot.loading) { - return t("FilePage.previewOriginalLoading"); - } - return slot.full ? t("FilePage.previewOriginalLoaded") : t("FilePage.previewOriginal"); + const slot = slots.value[index.value]; + if (!slot) { + return t("FilePage.previewOriginal"); + } + if (slot.loading) { + return t("FilePage.previewOriginalLoading"); + } + return slot.full ? t("FilePage.previewOriginalLoaded") : t("FilePage.previewOriginal"); }); /** 拉取中/已加载时禁用高清钮,避免重复触发 */ const buttonDisabled = computed(() => { - const slot = slots.value[index.value]; - return !slot || slot.loading || !!slot.full; + const slot = slots.value[index.value]; + return !slot || slot.loading || !!slot.full; }); /** 按需拉原图:成功后立即原地替换当前图(同一 image 换 src 即重载,高清及时切换) */ async function loadOriginal(i = index.value): Promise { - const item = props.items?.[i]; - const slot = slots.value[i]; - if (!item || !slot || slot.loading || slot.full || !item.loadFull) { - return; - } - slot.loading = true; - try { - const url = await item.loadFull(); - slot.full = url; - slot.src = url; - } - catch (error: any) { - uni.showToast({ - title: error?.message || t("FilePage.previewOriginalFail"), - icon: "none", - }); - } - finally { - slot.loading = false; - } + const item = props.items?.[i]; + const slot = slots.value[i]; + if (!item || !slot || slot.loading || slot.full || !item.loadFull) { + return; + } + slot.loading = true; + try { + const url = await item.loadFull(); + slot.full = url; + slot.src = url; + } + catch (error: any) { + uni.showToast({ + title: error?.message || t("FilePage.previewOriginalFail"), + icon: "none", + }); + } + finally { + slot.loading = false; + } } /** @@ -164,180 +229,179 @@ async function loadOriginal(i = index.value): Promise { * 不让用户对着黑屏;同时清掉 full 让高清钮回到可重试状态 */ function onImageError(i: number): void { - const slot = slots.value[i]; - const item = props.items?.[i]; - if (!slot || !item || !slot.full || slot.src !== slot.full) { - return; - } - slot.src = item.thumb || ""; - slot.full = undefined; - uni.showToast({ - title: t("FilePage.previewOriginalFail"), - icon: "none", - }); + const slot = slots.value[i]; + const item = props.items?.[i]; + if (!slot || !item || !slot.full || slot.src !== slot.full) { + return; + } + slot.src = item.thumb || ""; + slot.full = undefined; + uni.showToast({ + title: t("FilePage.previewOriginalFail"), + icon: "none", + }); } diff --git a/uniapp/youjiala/src/stores/uploadQueue.ts b/uniapp/youjiala/src/stores/uploadQueue.ts index d9c4986..e7cc5d8 100644 --- a/uniapp/youjiala/src/stores/uploadQueue.ts +++ b/uniapp/youjiala/src/stores/uploadQueue.ts @@ -74,6 +74,17 @@ function extOf(fileName: string): string { return dot > 0 ? fileName.slice(dot) : ".ext"; } +/** + * 分片协议名唯一前缀(uid-序号-总数.ext 的 uid 段)。 + * pump() 是同步循环,同批并发任务几乎必在同一毫秒点着,只靠 Date.now() 会重名: + * App 端重名会让 copyTo 目标冲突或互相覆盖,H5 端重名会让分片文件互相覆盖; + * 重试任务也会重新准备,同样要换新名,故再拼模块级自增序号。 + */ +function uniqueUploadTag(): string { + seq += 1; + return `uni-${Date.now()}-${seq}`; +} + /** 分片大小:内网 3MB / 公网 1MB(旧系统 FileUpload 同口径;开发构建视为内网) */ function resolveChunkSize(): number { const publicNet = /localhost|127\.|192\.168\.|10\./.test(baseUrl); @@ -88,7 +99,7 @@ function prepareAppFilePath(item: UploadItem): Promise { // 真机验证前不引入分片循环(读不了本地字节切片) return copyIntoAppUploadDir( item.filePath, - `uni-${Date.now()}-1-1${extOf(item.name)}`, + `${uniqueUploadTag()}-1-1${extOf(item.name)}`, ); // #endif // #ifndef APP-PLUS @@ -202,7 +213,7 @@ export const useUploadQueueStore = defineStore( let aborted = false; const chunkSize = resolveChunkSize(); const total = Math.max(Math.ceil(blob.size / chunkSize), 1); - const uid = `uni-${Date.now()}`; + const uid = uniqueUploadTag(); const startedAt = Date.now(); let doneBytes = 0; @@ -272,7 +283,7 @@ export const useUploadQueueStore = defineStore( }; } - /** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败回退原路径,至少能拿到明确的后端报错 */ + /** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败直接判失败,不回退原路径 */ function uniUpload(item: UploadItem): UploadHandle { let task: UniApp.UploadTask | null = null; let aborted = false; @@ -310,7 +321,14 @@ export const useUploadQueueStore = defineStore( prepareAppFilePath(item) .then(start) - .catch(() => start(item.filePath)); + .catch(() => { + // 绝不能回退原路径重传:原文件名没有 uid-序号-总数 协议,后端 + // PackUploadDmsFile 拆不出分片序号会静默 return null(HTTP 200 空体), + // 任务反而被当成成功、库里却是 0 条——宁可显式失败让用户重试 + item.status = "failed"; + item.error = "文件准备失败"; + finish(item); + }); return { abort: () => { diff --git a/uniapp/youjiala/src/utils/appFileWrite.ts b/uniapp/youjiala/src/utils/appFileWrite.ts index 5a4c6c9..21f6923 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 逻辑层不保证存在,不依赖它): @@ -52,7 +52,9 @@ export function base64OfBytes(bytes: ArrayBuffer): string { const parts: string[] = []; for (let offset = 0; offset < data.length; offset += chunkSize) { parts.push( - base64OfChunk(data.subarray(offset, Math.min(offset + chunkSize, data.length))), + base64OfChunk( + data.subarray(offset, Math.min(offset + chunkSize, data.length)), + ), ); } return parts.join(""); @@ -71,27 +73,27 @@ const PUBLIC_DOWNLOAD_FOLDER = "有家啦"; 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"; } diff --git a/uniapp/youjiala/src/utils/dmsBytes.ts b/uniapp/youjiala/src/utils/dmsBytes.ts index bb0a945..77b98b0 100644 --- a/uniapp/youjiala/src/utils/dmsBytes.ts +++ b/uniapp/youjiala/src/utils/dmsBytes.ts @@ -13,7 +13,11 @@ import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor"; import { FileTypePolicy } from "@/policy/custom/fileTypePolicy"; import dmsFileService from "@/service/master/dmsFileService"; -import { base64OfBytes, mimeOf, writeAppFileFromBytes } from "@/utils/appFileWrite"; +import { + base64OfBytes, + mimeOf, + writeAppFileFromBytes, +} from "@/utils/appFileWrite"; /** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */ export interface FileBytes { @@ -27,21 +31,27 @@ export interface FileBytes { */ 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) { + if (head[0] === 0xff && head[1] === 0xd8) { return "image/jpeg"; } - if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E) { + 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) { + 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 + 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"; } @@ -54,8 +64,8 @@ async function fetchCombineBytes( 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; // 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致 @@ -118,7 +128,9 @@ export async function saveFileBytes(file: FileBytes): Promise { export function toPlayableUrl(file: FileBytes): string { // #ifdef H5 // Blob 必须带 MIME:Chrome 会嗅探无 type 的 blob,Safari/部分微信内核不嗅探,直接黑图 - return URL.createObjectURL(new Blob([file.bytes], { type: mimeOf(file.name) })); + return URL.createObjectURL( + new Blob([file.bytes], { type: mimeOf(file.name) }), + ); // #endif // #ifndef H5 // #ifdef APP-PLUS