ow
This commit is contained in:
1 parent
05ca116194
commit
1ff69f3cb3
4 files changed
+349
-253
No files matched your search
@@ -14,38 +14,43 @@ import { computed, ref, watch } from "vue";
|
|||||||
import { rotateLeftGlyph, rotateRightGlyph } from "@/utils/rotateGlyph";
|
import { rotateLeftGlyph, rotateRightGlyph } from "@/utils/rotateGlyph";
|
||||||
|
|
||||||
export interface ImageViewerItem {
|
export interface ImageViewerItem {
|
||||||
/** 列表缩略图直链(列表已加载过,秒显);空则打开时自动拉原图 */
|
/** 列表缩略图直链(列表已加载过,秒显);空则打开时自动拉原图 */
|
||||||
thumb?: string;
|
thumb?: string;
|
||||||
/** 原图按需加载器:页面注入,返回可播放地址(App 落盘路径 / H5 blob / 小程序本地路径) */
|
/** 原图按需加载器:页面注入,返回可播放地址(App 落盘路径 / H5 blob / 小程序本地路径) */
|
||||||
loadFull?: () => Promise<string>;
|
loadFull?: () => Promise<string>;
|
||||||
/** 展示名(顶部信息位) */
|
/** 展示名(顶部信息位) */
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Slot {
|
interface Slot {
|
||||||
/** 当前显示地址(缩略图或已加载的原图) */
|
/** 当前显示地址(缩略图或已加载的原图) */
|
||||||
src: string;
|
src: string;
|
||||||
/** 已加载的原图地址(未拉取为 undefined) */
|
/** 已加载的原图地址(未拉取为 undefined) */
|
||||||
full?: string;
|
full?: string;
|
||||||
/** 原图拉取在途标记 */
|
/** 原图拉取在途标记 */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** 当前图旋转角(左旋 -90 / 右旋 +90 累积,模 360) */
|
/** 当前图旋转角(左旋 -90 / 右旋 +90 累积,模 360) */
|
||||||
rotate: number;
|
rotate: number;
|
||||||
/** 当前图缩放倍率(放大 ×1.25 / 缩小 ÷1.25,0.6~4 之间) */
|
/** 当前图缩放倍率(放大 ×1.25 / 缩小 ÷1.25,0.6~4 之间) */
|
||||||
scale: number;
|
scale: number;
|
||||||
|
/** 平移量(px,屏幕轴;只有放大后单指拖动才会变,缩回 1 倍自动归零) */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** 拖动会话中:临时关掉 transform 过渡,图片跟手 */
|
||||||
|
dragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
items?: ImageViewerItem[];
|
items?: ImageViewerItem[];
|
||||||
}>(),
|
}>(),
|
||||||
{ items: () => [] },
|
{ items: () => [] },
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** 点关闭按钮 */
|
/** 点关闭按钮 */
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 旋转字形恒白,模块级生成一次(custom-icon.md:颜色固定 → 模块级 const)
|
// 旋转字形恒白,模块级生成一次(custom-icon.md:颜色固定 → 模块级 const)
|
||||||
@@ -54,109 +59,169 @@ const rotateRightIcon = rotateRightGlyph("#ffffff");
|
|||||||
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
// 图区尺寸缓存:拖动边界按「放大溢出量的一半」算,不必每帧调同步 API
|
||||||
|
const { windowWidth: viewWidth, windowHeight: viewHeight } = uni.getSystemInfoSync();
|
||||||
|
|
||||||
const slots = ref<Slot[]>([]);
|
const slots = ref<Slot[]>([]);
|
||||||
const index = ref(0);
|
const index = ref(0);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
index.value = 0;
|
index.value = 0;
|
||||||
slots.value = (props.items ?? []).map(item => ({
|
slots.value = (props.items ?? []).map(item => ({
|
||||||
src: item.thumb || "",
|
src: item.thumb || "",
|
||||||
full: undefined,
|
full: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
}));
|
x: 0,
|
||||||
// 没有缩略图的老文件:打开即自动拉原图兜底,不留白等用户点按钮
|
y: 0,
|
||||||
slots.value.forEach((_, i) => {
|
dragging: false,
|
||||||
if (!slots.value[i].src) {
|
}));
|
||||||
loadOriginal(i);
|
// 没有缩略图的老文件:打开即自动拉原图兜底,不留白等用户点按钮
|
||||||
|
slots.value.forEach((_, i) => {
|
||||||
|
if (!slots.value[i].src) {
|
||||||
|
loadOriginal(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
else {
|
||||||
}
|
revokeBlobs();
|
||||||
else {
|
// 关窗即释放高清地址:App 端是 data: URI(一张图几 MB),多图连览不能积压
|
||||||
revokeBlobs();
|
slots.value = [];
|
||||||
// 关窗即释放高清地址:App 端是 data: URI(一张图几 MB),多图连览不能积压
|
}
|
||||||
slots.value = [];
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 关闭时回收 H5 blob: 地址;App/小程序是本地文件路径,不处理(App 落盘文件正好当下次缓存) */
|
/** 关闭时回收 H5 blob: 地址;App/小程序是本地文件路径,不处理(App 落盘文件正好当下次缓存) */
|
||||||
function revokeBlobs(): void {
|
function revokeBlobs(): void {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
slots.value.forEach((slot) => {
|
slots.value.forEach((slot) => {
|
||||||
if (slot.full && slot.full.startsWith("blob:")) {
|
if (slot.full && slot.full.startsWith("blob:")) {
|
||||||
URL.revokeObjectURL(slot.full);
|
URL.revokeObjectURL(slot.full);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSwipeChange(e: any): void {
|
function onSwipeChange(e: any): void {
|
||||||
index.value = e?.detail?.current ?? 0;
|
index.value = e?.detail?.current ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 左旋/右旋:每图独立累积角,模 360 防无限增长 */
|
/** 左旋/右旋:每图独立累积角,模 360 防无限增长 */
|
||||||
function rotateCurrent(delta: number): void {
|
function rotateCurrent(delta: number): void {
|
||||||
const slot = slots.value[index.value];
|
const slot = slots.value[index.value];
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
slot.rotate = (slot.rotate + delta) % 360;
|
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 {
|
function zoomCurrent(factor: number): void {
|
||||||
const slot = slots.value[index.value];
|
const slot = slots.value[index.value];
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
slot.scale = Math.min(4, Math.max(0.6, slot.scale * factor));
|
slot.scale = Math.min(4, Math.max(0.6, slot.scale * factor));
|
||||||
|
clampOffset(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 高清钮文案:拉取中 / 已高清 / 高清 */
|
/** 高清钮文案:拉取中 / 已高清 / 高清 */
|
||||||
const buttonText = computed(() => {
|
const buttonText = computed(() => {
|
||||||
const slot = slots.value[index.value];
|
const slot = slots.value[index.value];
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
return t("FilePage.previewOriginal");
|
return t("FilePage.previewOriginal");
|
||||||
}
|
}
|
||||||
if (slot.loading) {
|
if (slot.loading) {
|
||||||
return t("FilePage.previewOriginalLoading");
|
return t("FilePage.previewOriginalLoading");
|
||||||
}
|
}
|
||||||
return slot.full ? t("FilePage.previewOriginalLoaded") : t("FilePage.previewOriginal");
|
return slot.full ? t("FilePage.previewOriginalLoaded") : t("FilePage.previewOriginal");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 拉取中/已加载时禁用高清钮,避免重复触发 */
|
/** 拉取中/已加载时禁用高清钮,避免重复触发 */
|
||||||
const buttonDisabled = computed(() => {
|
const buttonDisabled = computed(() => {
|
||||||
const slot = slots.value[index.value];
|
const slot = slots.value[index.value];
|
||||||
return !slot || slot.loading || !!slot.full;
|
return !slot || slot.loading || !!slot.full;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 按需拉原图:成功后立即原地替换当前图(同一 image 换 src 即重载,高清及时切换) */
|
/** 按需拉原图:成功后立即原地替换当前图(同一 image 换 src 即重载,高清及时切换) */
|
||||||
async function loadOriginal(i = index.value): Promise<void> {
|
async function loadOriginal(i = index.value): Promise<void> {
|
||||||
const item = props.items?.[i];
|
const item = props.items?.[i];
|
||||||
const slot = slots.value[i];
|
const slot = slots.value[i];
|
||||||
if (!item || !slot || slot.loading || slot.full || !item.loadFull) {
|
if (!item || !slot || slot.loading || slot.full || !item.loadFull) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
slot.loading = true;
|
slot.loading = true;
|
||||||
try {
|
try {
|
||||||
const url = await item.loadFull();
|
const url = await item.loadFull();
|
||||||
slot.full = url;
|
slot.full = url;
|
||||||
slot.src = url;
|
slot.src = url;
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: error?.message || t("FilePage.previewOriginalFail"),
|
title: error?.message || t("FilePage.previewOriginalFail"),
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
slot.loading = false;
|
slot.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,180 +229,179 @@ async function loadOriginal(i = index.value): Promise<void> {
|
|||||||
* 不让用户对着黑屏;同时清掉 full 让高清钮回到可重试状态
|
* 不让用户对着黑屏;同时清掉 full 让高清钮回到可重试状态
|
||||||
*/
|
*/
|
||||||
function onImageError(i: number): void {
|
function onImageError(i: number): void {
|
||||||
const slot = slots.value[i];
|
const slot = slots.value[i];
|
||||||
const item = props.items?.[i];
|
const item = props.items?.[i];
|
||||||
if (!slot || !item || !slot.full || slot.src !== slot.full) {
|
if (!slot || !item || !slot.full || slot.src !== slot.full) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
slot.src = item.thumb || "";
|
slot.src = item.thumb || "";
|
||||||
slot.full = undefined;
|
slot.full = undefined;
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t("FilePage.previewOriginalFail"),
|
title: t("FilePage.previewOriginalFail"),
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view v-if="visible" class="iv" @touchmove.stop.prevent>
|
<view v-if="visible" class="iv" @touchstart="onImageTouchStart" @touchmove.stop.prevent="onImageTouchMove"
|
||||||
<!-- 顶部:计数 + 工具组(左旋/右旋/放大/缩小/高清/关闭,状态栏避让同 photo-slide-player 口径) -->
|
@touchend="onImageTouchEnd" @touchcancel="onImageTouchEnd">
|
||||||
<view class="iv-top">
|
<!-- 顶部:计数 + 工具组(左旋/右旋/放大/缩小/高清/关闭,状态栏避让同 photo-slide-player 口径) -->
|
||||||
<text class="iv-count">
|
<view class="iv-top">
|
||||||
{{ index + 1 }} / {{ slots.length }}
|
<text class="iv-count">
|
||||||
</text>
|
{{ index + 1 }} / {{ slots.length }}
|
||||||
<view class="iv-tools">
|
</text>
|
||||||
<view class="iv-tool" @click="rotateCurrent(-90)">
|
<view class="iv-tools">
|
||||||
<image class="iv-tool-img" :src="rotateLeftIcon" mode="aspectFit" />
|
<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>
|
</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 翻页(单指手势让给拖动) -->
|
||||||
<swiper-item v-for="(slot, i) in slots" :key="i" class="iv-pane">
|
<swiper class="iv-swiper" :current="index" :disable-touch="panEnabled" @change="onSwipeChange">
|
||||||
<image
|
<swiper-item v-for="(slot, i) in slots" :key="i" class="iv-pane">
|
||||||
v-if="slot.src"
|
<image v-if="slot.src" class="iv-img" :class="{ 'iv-img--dragging': slot.dragging }" :src="slot.src"
|
||||||
class="iv-img"
|
mode="aspectFit"
|
||||||
:src="slot.src"
|
:style="{ transform: `translate(${slot.x}px, ${slot.y}px) rotate(${slot.rotate}deg) scale(${slot.scale})` }"
|
||||||
mode="aspectFit"
|
@error="onImageError(i)" />
|
||||||
: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") }}
|
||||||
<view v-else class="iv-wait">
|
</text>
|
||||||
<text class="iv-wait-text">
|
</view>
|
||||||
{{ t("FilePage.previewOriginalLoading") }}
|
</swiper-item>
|
||||||
</text>
|
</swiper>
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
|
||||||
</swiper>
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.iv {
|
.iv {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: rgba(0, 0, 0, 0.96);
|
background: rgba(0, 0, 0, 0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 顶部栏 ===== */
|
/* ===== 顶部栏 ===== */
|
||||||
.iv-top {
|
.iv-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
|
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
|
||||||
/* #ifdef APP-PLUS */
|
/* #ifdef APP-PLUS */
|
||||||
padding-top: calc(24rpx + var(--status-bar-height, 0px));
|
padding-top: calc(24rpx + var(--status-bar-height, 0px));
|
||||||
/* #endif */
|
/* #endif */
|
||||||
/* #ifndef APP-PLUS */
|
/* #ifndef APP-PLUS */
|
||||||
padding-top: calc(24rpx + env(safe-area-inset-top));
|
padding-top: calc(24rpx + env(safe-area-inset-top));
|
||||||
/* #endif */
|
/* #endif */
|
||||||
padding-right: 24rpx;
|
padding-right: 24rpx;
|
||||||
padding-bottom: 16rpx;
|
padding-bottom: 16rpx;
|
||||||
padding-left: 32rpx;
|
padding-left: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-count {
|
.iv-count {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-tools {
|
.iv-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具钮:圆形半透明底;高清/关闭同款容器保证对齐 */
|
/* 工具钮:圆形半透明底;高清/关闭同款容器保证对齐 */
|
||||||
.iv-tool {
|
.iv-tool {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 72rpx;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.14);
|
background: rgba(255, 255, 255, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-tool-img {
|
.iv-tool-img {
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 高清文字钮:胶囊底,宽随文案 */
|
/* 高清文字钮:胶囊底,宽随文案 */
|
||||||
.iv-tool--text {
|
.iv-tool--text {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0 28rpx;
|
padding: 0 28rpx;
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.45);
|
border: 2rpx solid rgba(255, 255, 255, 0.45);
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-tool-text {
|
.iv-tool-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-tool.is-disabled {
|
.iv-tool.is-disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 图区 ===== */
|
/* ===== 图区 ===== */
|
||||||
.iv-swiper {
|
.iv-swiper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-pane {
|
.iv-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-img {
|
.iv-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* 旋转/缩放平滑过渡(0.25s 足够跟手又不会显得迟滞) */
|
/* 旋转/缩放平滑过渡(0.25s 足够跟手又不会显得迟滞) */
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖动中关掉过渡:否则图片会追着手指爬,跟手感全无 */
|
||||||
|
.iv-img--dragging {
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-wait {
|
.iv-wait {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iv-wait-text {
|
.iv-wait-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -74,6 +74,17 @@ function extOf(fileName: string): string {
|
|||||||
return dot > 0 ? fileName.slice(dot) : ".ext";
|
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 同口径;开发构建视为内网) */
|
/** 分片大小:内网 3MB / 公网 1MB(旧系统 FileUpload 同口径;开发构建视为内网) */
|
||||||
function resolveChunkSize(): number {
|
function resolveChunkSize(): number {
|
||||||
const publicNet = /localhost|127\.|192\.168\.|10\./.test(baseUrl);
|
const publicNet = /localhost|127\.|192\.168\.|10\./.test(baseUrl);
|
||||||
@@ -88,7 +99,7 @@ function prepareAppFilePath(item: UploadItem): Promise<string> {
|
|||||||
// 真机验证前不引入分片循环(读不了本地字节切片)
|
// 真机验证前不引入分片循环(读不了本地字节切片)
|
||||||
return copyIntoAppUploadDir(
|
return copyIntoAppUploadDir(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
`uni-${Date.now()}-1-1${extOf(item.name)}`,
|
`${uniqueUploadTag()}-1-1${extOf(item.name)}`,
|
||||||
);
|
);
|
||||||
// #endif
|
// #endif
|
||||||
// #ifndef APP-PLUS
|
// #ifndef APP-PLUS
|
||||||
@@ -202,7 +213,7 @@ export const useUploadQueueStore = defineStore(
|
|||||||
let aborted = false;
|
let aborted = false;
|
||||||
const chunkSize = resolveChunkSize();
|
const chunkSize = resolveChunkSize();
|
||||||
const total = Math.max(Math.ceil(blob.size / chunkSize), 1);
|
const total = Math.max(Math.ceil(blob.size / chunkSize), 1);
|
||||||
const uid = `uni-${Date.now()}`;
|
const uid = uniqueUploadTag();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let doneBytes = 0;
|
let doneBytes = 0;
|
||||||
|
|
||||||
@@ -272,7 +283,7 @@ export const useUploadQueueStore = defineStore(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败回退原路径,至少能拿到明确的后端报错 */
|
/** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败直接判失败,不回退原路径 */
|
||||||
function uniUpload(item: UploadItem): UploadHandle {
|
function uniUpload(item: UploadItem): UploadHandle {
|
||||||
let task: UniApp.UploadTask | null = null;
|
let task: UniApp.UploadTask | null = null;
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
@@ -310,7 +321,14 @@ export const useUploadQueueStore = defineStore(
|
|||||||
|
|
||||||
prepareAppFilePath(item)
|
prepareAppFilePath(item)
|
||||||
.then(start)
|
.then(start)
|
||||||
.catch(() => start(item.filePath));
|
.catch(() => {
|
||||||
|
// 绝不能回退原路径重传:原文件名没有 uid-序号-总数 协议,后端
|
||||||
|
// PackUploadDmsFile 拆不出分片序号会静默 return null(HTTP 200 空体),
|
||||||
|
// 任务反而被当成成功、库里却是 0 条——宁可显式失败让用户重试
|
||||||
|
item.status = "failed";
|
||||||
|
item.error = "文件准备失败";
|
||||||
|
finish(item);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abort: () => {
|
abort: () => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ declare const plus: any;
|
|||||||
/** 单次过桥字节数:512KB 转 base64 约 683KB 字符串,兼顾内存峰值与调用次数 */
|
/** 单次过桥字节数:512KB 转 base64 约 683KB 字符串,兼顾内存峰值与调用次数 */
|
||||||
const BYTES_PER_TRIP = 512 * 1024;
|
const BYTES_PER_TRIP = 512 * 1024;
|
||||||
|
|
||||||
const BASE64_CHARS
|
const BASE64_CHARS =
|
||||||
= "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 纯 JS base64(浏览器 btoa 在 App 逻辑层不保证存在,不依赖它):
|
* 纯 JS base64(浏览器 btoa 在 App 逻辑层不保证存在,不依赖它):
|
||||||
@@ -52,7 +52,9 @@ export function base64OfBytes(bytes: ArrayBuffer): string {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
||||||
parts.push(
|
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("");
|
return parts.join("");
|
||||||
@@ -71,27 +73,27 @@ const PUBLIC_DOWNLOAD_FOLDER = "有家啦";
|
|||||||
export function mimeOf(fileName: string): string {
|
export function mimeOf(fileName: string): string {
|
||||||
const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
|
const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
"jpg": "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
"jpeg": "image/jpeg",
|
jpeg: "image/jpeg",
|
||||||
"png": "image/png",
|
png: "image/png",
|
||||||
"gif": "image/gif",
|
gif: "image/gif",
|
||||||
"webp": "image/webp",
|
webp: "image/webp",
|
||||||
"bmp": "image/bmp",
|
bmp: "image/bmp",
|
||||||
"mp3": "audio/mpeg",
|
mp3: "audio/mpeg",
|
||||||
"m4a": "audio/mp4",
|
m4a: "audio/mp4",
|
||||||
"wav": "audio/wav",
|
wav: "audio/wav",
|
||||||
"aac": "audio/aac",
|
aac: "audio/aac",
|
||||||
"mp4": "video/mp4",
|
mp4: "video/mp4",
|
||||||
"mov": "video/quicktime",
|
mov: "video/quicktime",
|
||||||
"3gp": "video/3gpp",
|
"3gp": "video/3gpp",
|
||||||
"pdf": "application/pdf",
|
pdf: "application/pdf",
|
||||||
"txt": "text/plain",
|
txt: "text/plain",
|
||||||
"json": "application/json",
|
json: "application/json",
|
||||||
"xls": "application/vnd.ms-excel",
|
xls: "application/vnd.ms-excel",
|
||||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"doc": "application/msword",
|
doc: "application/msword",
|
||||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"zip": "application/zip",
|
zip: "application/zip",
|
||||||
};
|
};
|
||||||
return map[ext] || "application/octet-stream";
|
return map[ext] || "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@
|
|||||||
import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor";
|
import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor";
|
||||||
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
|
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
|
||||||
import dmsFileService from "@/service/master/dmsFileService";
|
import dmsFileService from "@/service/master/dmsFileService";
|
||||||
import { base64OfBytes, mimeOf, writeAppFileFromBytes } from "@/utils/appFileWrite";
|
import {
|
||||||
|
base64OfBytes,
|
||||||
|
mimeOf,
|
||||||
|
writeAppFileFromBytes,
|
||||||
|
} from "@/utils/appFileWrite";
|
||||||
|
|
||||||
/** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */
|
/** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */
|
||||||
export interface FileBytes {
|
export interface FileBytes {
|
||||||
@@ -27,21 +31,27 @@ export interface FileBytes {
|
|||||||
*/
|
*/
|
||||||
function imageMimeOf(bytes: ArrayBuffer, name: string): string {
|
function imageMimeOf(bytes: ArrayBuffer, name: string): string {
|
||||||
const head = new Uint8Array(bytes, 0, Math.min(12, bytes.byteLength));
|
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";
|
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";
|
return "image/png";
|
||||||
}
|
}
|
||||||
if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46) {
|
if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46) {
|
||||||
return "image/gif";
|
return "image/gif";
|
||||||
}
|
}
|
||||||
if (head[0] === 0x42 && head[1] === 0x4D) {
|
if (head[0] === 0x42 && head[1] === 0x4d) {
|
||||||
return "image/bmp";
|
return "image/bmp";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46
|
head[0] === 0x52 &&
|
||||||
&& head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50
|
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 "image/webp";
|
||||||
}
|
}
|
||||||
@@ -54,8 +64,8 @@ async function fetchCombineBytes(
|
|||||||
fileSizeKb: number,
|
fileSizeKb: number,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const segments = orderKey.split("-");
|
const segments = orderKey.split("-");
|
||||||
const total
|
const total =
|
||||||
= Number.parseInt(segments[segments.length - 1].split(".")[0], 10) || 1;
|
Number.parseInt(segments[segments.length - 1].split(".")[0], 10) || 1;
|
||||||
const merged = new Uint8Array((fileSizeKb + 1) * 1024);
|
const merged = new Uint8Array((fileSizeKb + 1) * 1024);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
// 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致
|
// 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致
|
||||||
@@ -118,7 +128,9 @@ export async function saveFileBytes(file: FileBytes): Promise<void> {
|
|||||||
export function toPlayableUrl(file: FileBytes): string {
|
export function toPlayableUrl(file: FileBytes): string {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// Blob 必须带 MIME:Chrome 会嗅探无 type 的 blob,Safari/部分微信内核不嗅探,直接黑图
|
// 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
|
// #endif
|
||||||
// #ifndef H5
|
// #ifndef H5
|
||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
|
|||||||
Reference in new issue
Block a user