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>