This commit is contained in:
owenchen committed 2026-10-21 09:31:20 +08:00
1 parent f1e6e116c3
commit 05ca116194
8 files changed
+519 -52

No files matched your search

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { TransitionPreset } from "uview-pro/types";
import type { PropType } from "vue";
import { $u } from "uview-pro";
import { reactive } from "vue";
@@ -50,7 +51,7 @@ const background = reactive({
* 动画结束又消失——这就是切 tab 时「底部闪一下」的来源;H5 由 CSS 控制滚动条、小程序无此机制,故只有 App 闪。
* H5/小程序保留原来的侧滑观感。
*/
let enterTransition = "slide-left";
let enterTransition: TransitionPreset = "slide-left";
// #ifdef APP-PLUS || APP-HARMONY
enterTransition = "fade";
// #endif
@@ -0,0 +1,343 @@
<script setup lang="ts">
/**
* 网盘图片查看器(自绘全屏,替代 uni.previewImage 的慢链路):
* 打开秒显列表已缓存的缩略图(thumb),原图按需拉取——顶部「高清」文字钮触发
* 页面注入的 loadFull(分片拼整 + 落盘),就绪后立即原地替换为高清图。
* 顶部工具组(左旋/右旋/放大/缩小/高清/关闭):旋转与缩放是每张图独立状态,
* 换图后各自恢复;旋转字形为自绘 SVG(utils/rotateGlyph,图标库无旋转语义)。
* 缩略图缺失(老文件没有 Scale 图)时打开即自动拉原图兜底。
* 关闭时回收 H5 blob: 地址(App 端是落盘文件路径,留着下次预览复用)。
* 组件不认识 service/记录类型:原图加载器由页面以函数注入(与 admin 组件「不认识 service」约定一致)。
*/
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { rotateLeftGlyph, rotateRightGlyph } from "@/utils/rotateGlyph";
export interface ImageViewerItem {
/** 列表缩略图直链(列表已加载过,秒显);空则打开时自动拉原图 */
thumb?: string;
/** 原图按需加载器:页面注入,返回可播放地址(App 落盘路径 / H5 blob / 小程序本地路径) */
loadFull?: () => Promise<string>;
/** 展示名(顶部信息位) */
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;
}
const props = withDefaults(
defineProps<{
visible: boolean;
items?: ImageViewerItem[];
}>(),
{ items: () => [] },
);
const emit = defineEmits<{
/** 点关闭按钮 */
(e: "close"): void;
}>();
// 旋转字形恒白,模块级生成一次(custom-icon.md:颜色固定 → 模块级 const)
const rotateLeftIcon = rotateLeftGlyph("#ffffff");
const rotateRightIcon = rotateRightGlyph("#ffffff");
const { t } = useLocale();
const slots = ref<Slot[]>([]);
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);
}
});
}
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
}
function onSwipeChange(e: any): void {
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;
}
/** 放大/缩小:倍率夹在 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 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 buttonDisabled = computed(() => {
const slot = slots.value[index.value];
return !slot || slot.loading || !!slot.full;
});
/** 按需拉原图:成功后立即原地替换当前图(同一 image 换 src 即重载,高清及时切换) */
async function loadOriginal(i = index.value): Promise<void> {
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;
}
}
/**
* 图片解码失败兜底:高清源打不开(路径不被 image 组件识别/格式不支持)就退回缩略图,
* 不让用户对着黑屏;同时清掉 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",
});
}
</script>
<template>
<view v-if="visible" class="iv" @touchmove.stop.prevent>
<!-- 顶部:计数 + 工具组(左旋/右旋/放大/缩小/高清/关闭,状态栏避让同 photo-slide-player 口径) -->
<view class="iv-top">
<text class="iv-count">
{{ index + 1 }} / {{ slots.length }}
</text>
<view class="iv-tools">
<view class="iv-tool" @click="rotateCurrent(-90)">
<image class="iv-tool-img" :src="rotateLeftIcon" mode="aspectFit" />
</view>
<view class="iv-tool" @click="rotateCurrent(90)">
<image class="iv-tool-img" :src="rotateRightIcon" mode="aspectFit" />
</view>
<view class="iv-tool" @click="zoomCurrent(1.25)">
<u-icon name="plus" size="40" color="#ffffff" />
</view>
<view class="iv-tool" @click="zoomCurrent(0.8)">
<u-icon name="minus" size="40" color="#ffffff" />
</view>
<!-- 高清用文字(需求约定),拉取中/已加载禁用 -->
<view
class="iv-tool iv-tool--text"
:class="{ 'is-disabled': buttonDisabled }"
@click="loadOriginal()"
>
<text class="iv-tool-text">
{{ buttonText }}
</text>
</view>
<view class="iv-tool" @click="emit('close')">
<u-icon name="close" size="40" color="#ffffff" />
</view>
</view>
</view>
<!-- 图区:左右滑动连览,每张按「缩略图 → 高清」渐进升级,旋转/缩放按图独立 -->
<swiper class="iv-swiper" :current="index" @change="onSwipeChange">
<swiper-item v-for="(slot, i) in slots" :key="i" class="iv-pane">
<image
v-if="slot.src"
class="iv-img"
:src="slot.src"
mode="aspectFit"
:style="{ transform: `rotate(${slot.rotate}deg) scale(${slot.scale})` }"
@error="onImageError(i)"
/>
<!-- 缩略图缺失时的拉取占位(打开即自动拉原图,这里只在途提示) -->
<view v-else class="iv-wait">
<text class="iv-wait-text">
{{ t("FilePage.previewOriginalLoading") }}
</text>
</view>
</swiper-item>
</swiper>
</view>
</template>
<style lang="scss" scoped>
.iv {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.96);
}
/* ===== 顶部栏 ===== */
.iv-top {
display: flex;
align-items: center;
justify-content: space-between;
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
/* #ifdef APP-PLUS */
padding-top: calc(24rpx + var(--status-bar-height, 0px));
/* #endif */
/* #ifndef APP-PLUS */
padding-top: calc(24rpx + env(safe-area-inset-top));
/* #endif */
padding-right: 24rpx;
padding-bottom: 16rpx;
padding-left: 32rpx;
}
.iv-count {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
}
.iv-tools {
display: flex;
align-items: center;
gap: 20rpx;
}
/* 工具钮:圆形半透明底;高清/关闭同款容器保证对齐 */
.iv-tool {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
}
.iv-tool-img {
width: 40rpx;
height: 40rpx;
}
/* 高清文字钮:胶囊底,宽随文案 */
.iv-tool--text {
width: auto;
padding: 0 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.45);
border-radius: 999rpx;
}
.iv-tool-text {
font-size: 26rpx;
color: #ffffff;
white-space: nowrap;
}
.iv-tool.is-disabled {
opacity: 0.55;
}
/* ===== 图区 ===== */
.iv-swiper {
flex: 1;
min-height: 0;
width: 100%;
}
.iv-pane {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.iv-img {
width: 100%;
height: 100%;
/* 旋转/缩放平滑过渡(0.25s 足够跟手又不会显得迟滞) */
transition: transform 0.25s ease;
}
.iv-wait {
display: flex;
align-items: center;
justify-content: center;
}
.iv-wait-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
</style>
@@ -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",
@@ -650,6 +650,10 @@
"downloadDoneApp": "已保存到 手机存储/Download/有家啦",
"downloading": "下载中…",
"previewNoImage": "没有可预览的图片",
"previewOriginal": "高清",
"previewOriginalLoading": "加载中…",
"previewOriginalLoaded": "已高清",
"previewOriginalFail": "高清图加载失败",
"loadingMedia": "媒体准备中…",
"pleaseWait": "处理中…",
"actionFailed": "操作失败",
+29 -16
View File
@@ -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<void> {
}, t("FilePage.downloading"));
}
// ----- 图片查看器(缩略图秒开 + 按需原图,见 components/image-viewer)-----
const viewerVisible = ref(false);
const viewerItems = ref<ImageViewerItem[]>([]);
/** 图片预览:单选一张、多选多图相册连览。
* 走原图拼整通道(旧 MyComputer @preview-image → GetOrgPictureFile 就是分片拼整);
* DownloadFilePreview 是旧缩略图通道,依赖上传时生成的 scale 目录,本地库没有缩略图只会 400 */
async function actionPreview(targets: FileItem[]): Promise<void> {
* 缩略图先行——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");
<!-- 弹层(操作面板/抽屉/对话框)或多选态展开时收起 tabbar:tabbar 在页面 fixed 层叠上下文之外,
弹层 z-index 再高也压不住它,直接隐藏最干净(主流云盘同款交互) -->
<app-page :hide-nav="true"
:show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible && !renameVisible && !moveVisible && !shareVisible && !publishVisible">
:show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible && !renameVisible && !moveVisible && !shareVisible && !publishVisible && !viewerVisible">
<view class="file-page" :style="{ background: themeGlowBackground }">
<!-- 上半屏整体固定,只有文件列表所在的 scroll-view 内部滚动(整页不滚) -->
<view class="file-page__head">
@@ -1023,6 +1034,8 @@ const transferIconWhite = transferGlyph("#ffffff");
</view>
<file-action-sheet :visible="!!actionFile" :file="actionFile" @close="actionFile = null"
@action="onSheetAction" />
<!-- 图片查看器:缩略图秒开连览,底部「查看原图」按需拉原图(全屏遮罩,打开时收起 tabbar) -->
<image-viewer :visible="viewerVisible" :items="viewerItems" @close="viewerVisible = false" />
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
<!-- 重命名:同一个命名对话框的第二个实例(预填当前名 + 重命名文案) -->
+52 -28
View File
@@ -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<string, string> = {
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/<fileName>,返回**绝对路径**:
* uni.openDocument / previewImage / video / audio 在 App 上都能直接吃这个路径。
* 把整段字节写成 _doc/files/<fileName>,返回本地相对路径 `_doc/files/<fileName>`:
* 为什么不是绝对路径——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 "";
+53 -7
View File
@@ -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<ArrayBuffer> {
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<void> {
/**
* 生成可预览/可播放的地址:
* 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
}
+32
View File
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${color}" d="${segments.join("")}" /></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);
}