add:新增保存功能

This commit is contained in:
2026-06-11 10:26:53 +08:00
parent 1f1581321c
commit d03cf59335
4 changed files with 1054 additions and 250 deletions
+1
View File
@@ -137,6 +137,7 @@
class="h-[80vh] object-contain rounded-md mx-auto" class="h-[80vh] object-contain rounded-md mx-auto"
/> />
<template #footer> <template #footer>
<span class="text-sm text-gray-500">长按保存图片</span>
<el-button type="info" plain @click="handleClose">关闭</el-button> <el-button type="info" plain @click="handleClose">关闭</el-button>
<!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> --> <!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> -->
<!-- 保存 --> <!-- 保存 -->
+135 -250
View File
@@ -1,9 +1,8 @@
<template> <template>
<!-- <div v-if="taskInfo && taskTypes.length"> -->
<div class="task-production-detail"> <div class="task-production-detail">
<div <div
class="task-production-detail-header flex items-center justify-between" class="task-production-detail-header flex items-center justify-between"
:class="[isMobile ? 'p-2' : 'p-4']" :class="[isMobile ? 'p-2 pt-[safe-area-inset-top] pt-[44px]' : 'p-4']"
> >
<p <p
class="text-white font-bold one-ell" class="text-white font-bold one-ell"
@@ -11,7 +10,10 @@
> >
作品集 作品集
</p> </p>
<div class="flex items-center gap-2 bg-[#b3d5ff] rounded-lg flex-1"> <div
class="flex items-center gap-2 bg-[#b3d5ff] rounded-lg flex-1"
:class="[isMobile ? 'w-full' : 'max-w-[300px]']"
>
<el-input <el-input
v-model="searchForm.title" v-model="searchForm.title"
placeholder="请输入作品标题搜索对应作品" placeholder="请输入作品标题搜索对应作品"
@@ -26,7 +28,7 @@
/></el-icon> /></el-icon>
</div> </div>
</div> </div>
<!-- 滚动容器 ref 绑定 -->
<div <div
ref="scrollContainer" ref="scrollContainer"
class="card-scroll-container" class="card-scroll-container"
@@ -62,7 +64,7 @@
" "
/> />
<div <div
class="flex gap-2 items-center justify-start" class="flex gap-2 items-center justify-start flex-wrap"
:class="[isMobile ? 'p-2' : 'p-4']" :class="[isMobile ? 'p-2' : 'p-4']"
> >
<el-tag <el-tag
@@ -78,7 +80,6 @@
</div> </div>
</template> </template>
</Waterfall> </Waterfall>
<!-- 加载状态提示 -->
<div class="loading-tip"> <div class="loading-tip">
<span v-if="isLoading">加载中...</span> <span v-if="isLoading">加载中...</span>
<span v-else-if="!hasMore">没有更多了</span> <span v-else-if="!hasMore">没有更多了</span>
@@ -91,6 +92,7 @@
/> />
</div> </div>
<!-- PC端抽屉 -->
<el-drawer <el-drawer
v-if="!isMobile" v-if="!isMobile"
v-model="visible" v-model="visible"
@@ -125,30 +127,53 @@
</p> </p>
</div> </div>
<template #footer> <template #footer>
<el-button type="info" plain @click="handleClose">关闭</el-button> <el-button type="default" plain @click="handleClose">关闭</el-button>
<!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> --> <!-- 全环境通用保存按钮 -->
<!-- 保存 --> <el-button type="primary" @click="handleSave" :loading="saving">
<!-- <el-button type="primary" @click="handleSave">保存</el-button> --> 保存
</el-button>
</template> </template>
</el-drawer> </el-drawer>
<el-dialog
<!-- 移动端弹窗 -->
<!-- <el-dialog
v-if="isMobile" v-if="isMobile"
v-model="visible" v-model="visible"
:size="500" :size="500"
@close="handleClose" @close="handleClose"
fullscreen fullscreen
> -->
<div
v-if="isMobile && visible"
class="fixed top-0 left-0 w-full h-full bg-black bg-black/75 z-10"
@click="handleClose"
> >
<img <img
@click.stop
:src="currentItem.fileUrl" :src="currentItem.fileUrl"
class="h-[80vh] object-contain rounded-md mx-auto" class="w-full h-full object-contain rounded-md mx-auto"
/> />
<template #footer> <div
<el-button type="info" plain @click="handleClose">关闭</el-button> @click.stop
<!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> --> class="fixed bottom-3 left-0 w-full p-4 flex justify-end items-center gap-2 z-11 "
<!-- 保存 --> >
<!-- <el-button type="primary" @click="handleSave">保存</el-button> --> <el-button type="info" plain :icon="Close" @click="handleClose"
</template> >关闭</el-button
</el-dialog> >
<!-- circle -->
<!-- 全环境通用保存按钮 -->
<el-button
type="primary"
@click="handleSave"
:icon="Download"
color="#0074FE"
>
保存
</el-button>
</div>
<!-- </el-dialog> -->
</div>
<el-button <el-button
v-show="showScrollToTop" v-show="showScrollToTop"
type="primary" type="primary"
@@ -160,90 +185,67 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { ref, onMounted, onBeforeUnmount, nextTick, computed } from "vue";
ref,
reactive,
onMounted,
onBeforeUnmount,
nextTick,
computed,
} from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { debounce, throttle } from "lodash-es"; // 记得安装lodash-es import { debounce, throttle } from "lodash-es";
import { showLoading, hiddenLoading } from "@/utils/loading"; import { showLoading, hiddenLoading } from "@/utils/loading";
import { showMsg } from "@/utils/showMsg"; import { showMsg } from "@/utils/showMsg";
// Element Plus 图标 + 组件 import { Search, ArrowUp, Close, Download } from "@element-plus/icons-vue";
import { Refresh, ArrowLeft, Search, ArrowUp } from "@element-plus/icons-vue";
// 接口请求
import apis from "@/api/index"; import apis from "@/api/index";
// 子组件
import WaterfallFlow from "./waterfallFlow.vue";
// import DictTag from "@/components/DictTag/index.vue";
// 静态资源 & 工具函数
import { import {
DictOption, DictOption,
TaskInfo, TaskInfo,
WaterfallItem, WaterfallItem,
// MediaType,
WaterfallProps, WaterfallProps,
} from "@/typings/work"; } from "@/typings/work";
import defaultImg from "@/assets/images/img-load-err.png"; import defaultImg from "@/assets/images/img-load-err.png";
import fallbackCopyToClipboard from "@/utils/copyText"; import fallbackCopyToClipboard from "@/utils/copyText";
import { LazyImg, Waterfall } from "vue-waterfall-plugin-next"; import { Waterfall } from "vue-waterfall-plugin-next";
import "vue-waterfall-plugin-next/dist/style.css"; import "vue-waterfall-plugin-next/dist/style.css";
// 引入通用保存工具
import { saveFile, FileType } from "@/utils/saveFile";
enum MediaType { enum MediaType {
IMAGE = 1, // 纯图片 IMAGE = 1,
IMAGE_TEXT = 2, // 图文 IMAGE_TEXT = 2,
VIDEO = 3, // 视频 VIDEO = 3,
TEXT = 4, // 纯文字 TEXT = 4,
} }
const waterfallKey = ref(0); const waterfallKey = ref(0);
// ✅ 自定义响应式断点配置
const waterfallBreakpoints = { const waterfallBreakpoints = {
// 1200: { rowPerView: 4 }, // 宽度 < 1200px4列 600: { rowPerView: 2 },
// 900: { rowPerView: 3 }, // 宽度 < 900px3列 320: { rowPerView: 2 },
600: { rowPerView: 2 }, // 宽度 < 600px2列(平板横屏)
320: { rowPerView: 2 }, // 宽度 < 320px2列(所有手机竖屏)
}; };
const searchForm = ref({ const searchForm = ref({
title: "", title: "",
// type: 1, //默认图片
}); });
const search = async () => { const search = async () => {
currentPage.value = 1; currentPage.value = 1;
hasMore.value = true; hasMore.value = true;
scrollContainer.value.scrollTop = 0; scrollContainer.value?.scrollTo({ top: 0 });
await getTaskProductionListLimit(); await getTaskProductionListLimit();
}; };
const debouncedSearch = debounce(search, 300); const debouncedSearch = debounce(search, 300);
const { WOEKS_LIST_TWO } = apis; const { WOEKS_LIST_TWO } = apis;
// ===================== 1. 路由实例(Vue3 替代 this.$route =====================
const route = useRoute(); const route = useRoute();
const taskId = route.params.taskId as string; const taskId = route.params.taskId as string;
// 复用枚举:1图片 2图文 3视频 4文字
// ===================== 2. TS 类型定义 =====================
/** 任务基础信息 */
// ===================== 3. 全局响应式数据 =====================
// 字典类型列表
const taskTypes = ref<DictOption[]>( const taskTypes = ref<DictOption[]>(
JSON.parse(sessionStorage.getItem("task_type") || "[]") JSON.parse(sessionStorage.getItem("task_type") || "[]")
); );
// 任务详情
const taskInfo = ref<TaskInfo>( const taskInfo = ref<TaskInfo>(
JSON.parse(sessionStorage.getItem("currentTask") || "{}") JSON.parse(sessionStorage.getItem("currentTask") || "{}")
); );
const currentTaskType = computed(() => Number(taskInfo.value.type || 1)); const currentTaskType = computed(() => Number(taskInfo.value.type || 1));
// 瀑布流列表
const list = ref<WaterfallItem[]>([]); const list = ref<WaterfallItem[]>([]);
// 抽屉显隐
const visible = ref(false); const visible = ref(false);
// 当前选中的卡片项
const currentItem = ref<WaterfallItem | null>(null); const currentItem = ref<WaterfallItem | null>(null);
// 分页 & 加载状态
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = computed(() => (isMobile.value ? 20 : 50)); const pageSize = computed(() => (isMobile.value ? 20 : 50));
const isLoading = ref(false); const isLoading = ref(false);
@@ -252,84 +254,75 @@ const cardDom = computed(
() => document.getElementsByClassName("waterfall-item")[0] () => document.getElementsByClassName("waterfall-item")[0]
); );
const cardDomWidth = computed(() => cardDom.value?.offsetWidth ?? 200); const cardDomWidth = computed(() => cardDom.value?.offsetWidth ?? 200);
// 计算高度方法
const calculateHeight = (width: number, height: number) => { const calculateHeight = (width: number, height: number) => {
// console.log(11111111111111, cardDom, cardDomWidth.value, width, height);
// 16*200/9=
return (cardDomWidth.value * height) / width || 356; return (cardDomWidth.value * height) / width || 356;
}; };
// 滚动容器 DOM 引用(标注 TS 类型)
const scrollContainer = ref<HTMLDivElement | null>(null); const scrollContainer = ref<HTMLDivElement | null>(null);
const showScrollToTop = ref(false);
const saving = ref(false);
const isFirstLoad = ref(true);
// ===================== 4. 工具/业务方法 =====================
/** 返回上一页 */
const goBack = () => {
sessionStorage.clear();
window.history.go(-1);
};
/** 返回顶部 */
const scrollToTop = () => { const scrollToTop = () => {
scrollContainer.value?.scrollTo({ scrollContainer.value?.scrollTo({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
}); });
}; };
/** 刷新列表 */
const refresh = () => { const refresh = () => {
currentPage.value = 1; currentPage.value = 1;
list.value = []; list.value = [];
hasMore.value = true; hasMore.value = true;
getTaskProductionListLimit(); getTaskProductionListLimit();
}; };
/** 图片加载错误兜底 */
const handleImageError = (e: Event) => { const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.src = defaultImg; target.src = defaultImg;
}; };
/** 分页加载作品列表(无限滚动核心) */
const getTaskProductionListLimit = async () => { const getTaskProductionListLimit = async () => {
// 防重复请求
if (isLoading.value || !hasMore.value) return; if (isLoading.value || !hasMore.value) return;
isLoading.value = true; isLoading.value = true;
try { try {
currentPage.value == 1 && showLoading(); currentPage.value == 1 && showLoading();
const res = await WOEKS_LIST_TWO({ const res = await WOEKS_LIST_TWO({
// taskId,
current: currentPage.value, current: currentPage.value,
size: pageSize.value, size: pageSize.value,
...searchForm.value, ...searchForm.value,
}); });
const { records = [] } = res || {}; const { records = [] } = res || {};
const sizes = [
[9, 16],
[1, 1],
[16, 9],
[3, 4],
[4, 3],
[2, 3],
[3, 2],
];
records.forEach((item) => { records.forEach((item) => {
let sizes = [
[9, 16],
[1, 1],
[16, 9],
[3, 4],
[4, 3],
[2, 3],
[3, 2],
];
if (!item.width || !item.height) { if (!item.width || !item.height) {
let size = sizes[Math.floor(Math.random() * sizes.length)]; const size = sizes[Math.floor(Math.random() * sizes.length)];
item.width = size[0]; item.width = size[0];
item.height = size[1]; item.height = size[1];
} }
}); });
if (currentPage.value == 1) { if (currentPage.value == 1) {
list.value = records; list.value = records;
} else { } else {
list.value = list.value.concat(records); list.value = list.value.concat(records);
} }
// 判断是否还有更多数据
hasMore.value = !(records.length < pageSize.value); hasMore.value = !(records.length < pageSize.value);
} catch (error) { } catch (error) {
console.error("列表加载失败:", error); console.error("列表加载失败:", error);
// 加载失败,页码回退
currentPage.value = Math.max(1, currentPage.value - 1); currentPage.value = Math.max(1, currentPage.value - 1);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@@ -338,206 +331,111 @@ const getTaskProductionListLimit = async () => {
} }
} }
}; };
const showScrollToTop = ref(false);
/** 滚动触底加载 */
const handleScroll = throttle(() => { const handleScroll = throttle(() => {
const container = scrollContainer.value; const container = scrollContainer.value;
if (!container || isLoading.value || !hasMore.value) return; if (!container || isLoading.value || !hasMore.value) return;
const { scrollHeight, scrollTop, clientHeight } = container; const { scrollHeight, scrollTop, clientHeight } = container;
// 距离底部 50px 预加载
if (scrollTop + clientHeight >= scrollHeight - 50) { if (scrollTop + clientHeight >= scrollHeight - 50) {
currentPage.value++; currentPage.value++;
getTaskProductionListLimit(); getTaskProductionListLimit();
} }
if (scrollTop > 1000) {
showScrollToTop.value = true; showScrollToTop.value = scrollTop > 1000;
} else {
showScrollToTop.value = false;
}
}, 100); }, 100);
// 检测师手机端还是pc端,不要宽度判断,用userAgent判断
const isMobile = computed(() => { const isMobile = computed(() => {
return navigator.userAgent.includes("Mobile"); return navigator.userAgent.includes("Mobile");
}); });
/** 瀑布流卡片点击 */
const handleCardClick = (item: WaterfallItem) => { const handleCardClick = (item: WaterfallItem) => {
// if (isMobile.value) {
// } else {
visible.value = true; visible.value = true;
currentItem.value = item; currentItem.value = item;
console.log("点击作品:", item);
// }
}; };
const saving = ref(false);
/** 抽屉关闭 */
const handleClose = () => { const handleClose = () => {
visible.value = false; visible.value = false;
// currentItem.value = null;
}; };
/**
* ✅ 纯前端保存功能(无后端接口) const handleCopy = (text: string) => {
* 支持:图片/视频/纯文字三种类型 fallbackCopyToClipboard(text);
*/ showMsg("复制成功", "success");
};
// ==================== 简化后的保存方法(调用通用工具) ====================
const handleSave = async () => { const handleSave = async () => {
if (!currentItem.value) { if (!currentItem.value) {
showMsg("请先选择要保存的作品"); showMsg("请先选择要保存的作品");
return; return;
} }
if (saving.value) return; // 防止重复点击 if (saving.value) return;
saving.value = true; saving.value = true;
try { try {
const item = currentItem.value; const item = currentItem.value;
// 生成文件名:标题_时间戳.后缀(避免重复 // 生成安全的文件名(去除非法字符
const timestamp = Date.now();
const safeTitle = const safeTitle =
item.title?.replace(/[<>:"/\\|?*]/g, "_") || `work_${item.id}`; item.title?.replace(/[<>:"/\\|?*]/g, "_") || `work_${item.id}`;
// 根据作品类型调用通用保存工具
switch (currentTaskType.value) { switch (currentTaskType.value) {
// 图片类型:Canvas转Blob下载(解决跨域问题)
case MediaType.IMAGE: case MediaType.IMAGE:
await saveFile({
type: FileType.IMAGE,
url: item.fileUrl,
fileName: safeTitle,
});
break;
case MediaType.IMAGE_TEXT: case MediaType.IMAGE_TEXT:
await saveImage(item.fileUrl, `${safeTitle}_${timestamp}.png`); await saveFile({
type: FileType.IMAGE,
url: item.fileUrl,
fileName: safeTitle,
});
break; break;
// 视频类型:Fetch获取Blob下载
case MediaType.VIDEO: case MediaType.VIDEO:
await saveVideo(item.fileUrl, `${safeTitle}_${timestamp}.mp4`); await saveFile({
type: FileType.VIDEO,
url: item.fileUrl,
fileName: safeTitle,
});
break; break;
// 纯文字类型:生成TXT文件下载
case MediaType.TEXT: case MediaType.TEXT:
saveText(item.content, `${safeTitle}_${timestamp}.txt`); await saveFile({
type: FileType.TEXT,
content: item.content,
fileName: safeTitle,
});
break; break;
default: default:
showMsg("不支持的文件类型"); showMsg("不支持的文件类型");
break;
} }
if (!isMobile.value) {
showMsg("保存成功", "success"); showMsg("保存成功", "success");
handleClose(); }
// handleClose();
} catch (error) { } catch (error) {
console.error("保存失败:", error); console.error("保存失败:", error);
showMsg(error.message || "保存失败,请检查网络连接或稍后重试", "error"); showMsg(error.message || "保存失败,请稍后重试", "error");
} finally { } finally {
saving.value = false; saving.value = false;
} }
}; };
/** // ==================== 生命周期 ====================
* 保存图片(解决跨域问题)
* @param url 图片URL
* @param fileName 保存的文件名
*/
const saveImage = (url: string, fileName: string): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // 关键:允许跨域
img.onload = () => {
try {
// 创建Canvas
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas不支持"));
return;
}
// 绘制图片
ctx.drawImage(img, 0, 0);
// 转Blob并下载
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("图片转换失败"));
return;
}
downloadBlob(blob, fileName);
resolve();
}, "image/png");
} catch (e) {
reject(new Error("图片处理失败,可能是跨域限制"));
}
};
img.onerror = () => {
reject(new Error("图片加载失败"));
};
img.src = url;
});
};
/**
* 保存视频
* @param url 视频URL
* @param fileName 保存的文件名
*/
const saveVideo = async (url: string, fileName: string): Promise<void> => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`视频加载失败:${response.status}`);
}
const blob = await response.blob();
downloadBlob(blob, fileName);
} catch (error) {
throw new Error("视频下载失败,可能是文件过大或网络问题");
}
};
/**
* 保存纯文字为TXT文件
* @param text 文字内容
* @param fileName 保存的文件名
*/
const saveText = (text: string, fileName: string) => {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
downloadBlob(blob, fileName);
};
/**
* 通用Blob下载方法
* @param blob 要下载的Blob对象
* @param fileName 保存的文件名
*/
const downloadBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url); // 释放内存
};
/** 复制文案 */
const handleCopy = (text: string) => {
fallbackCopyToClipboard(text);
};
// 是否首次加载
const isFirstLoad = ref(true);
// ===================== 5. 生命周期 =====================
/** 组件挂载:初始化所有数据 + 绑定滚动事件 */
onMounted(async () => { onMounted(async () => {
// 纯文字类型,修改每页条数
// 重置分页 + 加载第一页
currentPage.value = 1; currentPage.value = 1;
hasMore.value = true; hasMore.value = true;
await getTaskProductionListLimit(); await getTaskProductionListLimit();
// 绑定滚动事件
await nextTick(); await nextTick();
const container = scrollContainer.value; const container = scrollContainer.value;
if (container) { if (container) {
container.addEventListener("scroll", handleScroll); container.addEventListener("scroll", handleScroll);
} }
setTimeout(() => { setTimeout(() => {
isFirstLoad.value = false; isFirstLoad.value = false;
waterfallKey.value++; waterfallKey.value++;
@@ -545,7 +443,6 @@ onMounted(async () => {
}, 500); }, 500);
}); });
/** 组件卸载:解绑滚动事件,防止内存泄漏 */
onBeforeUnmount(() => { onBeforeUnmount(() => {
const container = scrollContainer.value; const container = scrollContainer.value;
if (container) { if (container) {
@@ -562,14 +459,11 @@ onBeforeUnmount(() => {
.task-production-detail-header { .task-production-detail-header {
overflow-y: auto; overflow-y: auto;
background-color: #0072fed6; background-color: #0072fed6;
// border-bottom: 1px solid #e5e5e5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
// text-align: center;
// background-color: #0072fe;
> div { > div {
max-width: 300px; // max-width: 300px;
:deep(.el-input) { :deep(.el-input) {
// width: 100%;
flex: 1; flex: 1;
} }
} }
@@ -589,13 +483,6 @@ onBeforeUnmount(() => {
} }
.detail-content { .detail-content {
h3 {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
img, img,
video { video {
width: 100%; width: 100%;
@@ -606,22 +493,20 @@ onBeforeUnmount(() => {
} }
} }
/* Vue3 推荐使用 :deep() 替代 ::v-deep,两种都兼容 */
:deep(.el-drawer__title) { :deep(.el-drawer__title) {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
span {
text-align: left;
}
.card-item { .card-item {
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.829); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
} }
img { img {
transition: transform 0.3s ease;
&:hover { &:hover {
transform: scale(1.05); transform: scale(1.05);
transition: transform 0.3s ease;
} }
} }
} }
+632
View File
@@ -0,0 +1,632 @@
<template>
<!-- <div v-if="taskInfo && taskTypes.length"> -->
<div class="task-production-detail">
<div
class="task-production-detail-header flex items-center justify-between"
:class="[isMobile ? 'p-2' : 'p-4']"
>
<p
class="text-white font-bold one-ell"
:class="[isMobile ? 'mr-1 text-sm' : 'text-2xl ml-4']"
>
作品集
</p>
<div class="flex items-center gap-2 bg-[#b3d5ff] rounded-lg flex-1">
<el-input
v-model="searchForm.title"
placeholder="请输入作品标题搜索对应作品"
clearable
@input="debouncedSearch"
/>
<el-icon
@click="search"
class="text-[#0074FE] font-bold"
:class="[isMobile ? 'mr-2 text-lg' : 'mr-4 ml-2 text-2xl']"
><Search
/></el-icon>
</div>
</div>
<!-- 滚动容器 ref 绑定 -->
<div
ref="scrollContainer"
class="card-scroll-container"
:class="[isMobile ? '' : 'px-4']"
v-show="list && list.length"
>
<Waterfall
:key="waterfallKey"
:list="list"
:type="currentTaskType"
imgSelector="fileUrl"
show-loading
lazyload
:breakpoints="waterfallBreakpoints"
animation-effect="fadeInUp"
>
<template #default="{ item, url }">
<div
class="border rounded-md box-shadow-md rounded-xl overflow-hidden transition-all duration-300 card-item"
style="box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08)"
@click="handleCardClick(item)"
>
<img
:src="url"
class="w-full object-cover"
loading="lazy"
:style="
item.width && item.height
? {
height: calculateHeight(item.width, item.height) + 'px',
}
: {}
"
/>
<div
class="flex gap-2 items-center justify-start"
:class="[isMobile ? 'p-2' : 'p-4']"
>
<el-tag
v-for="tag in item.tags.split(',')"
:key="tag"
size="small"
plain
round
type="info"
>{{ tag }}</el-tag
>
</div>
</div>
</template>
</Waterfall>
<!-- 加载状态提示 -->
<div class="loading-tip">
<span v-if="isLoading">加载中...</span>
<span v-else-if="!hasMore">没有更多了</span>
</div>
</div>
<el-empty
v-show="!list || !list.length"
style="height: calc(100vh - 80px)"
description="没有更多了"
/>
</div>
<el-drawer
v-if="!isMobile"
v-model="visible"
:size="500"
@close="handleClose"
fullscreen
title="右键保存图片"
>
<div v-if="currentItem" class="detail-content">
<img
v-if="[MediaType.IMAGE, MediaType.IMAGE_TEXT].includes(currentTaskType)"
:src="currentItem.fileUrl"
@error="handleImageError"
/>
<video
v-if="[MediaType.VIDEO].includes(currentTaskType)"
:src="currentItem.fileUrl"
controls
/>
<p
v-if="[MediaType.IMAGE_TEXT, MediaType.TEXT].includes(currentTaskType)"
class="text-md mt-4 align-left"
>
<el-button
type="text"
@click.stop="handleCopy(currentItem.content)"
class="!p-0"
size="small"
>
复制文案
</el-button>
{{ currentItem.content }}
</p>
</div>
<template #footer>
<span class="">右键保存图片</span>
<el-button type="info" plain @click="handleClose">关闭</el-button>
<!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> -->
<!-- 保存 -->
<!-- <el-button type="primary" @click="handleSave">保存</el-button> -->
</template>
</el-drawer>
<el-dialog
v-if="isMobile"
v-model="visible"
:size="500"
@close="handleClose"
fullscreen
title="长按保存图片"
>
<img
:src="currentItem.fileUrl"
class="h-[80vh] object-contain rounded-md mx-auto"
/>
<template #footer>
<el-button type="info" plain @click="handleClose">关闭</el-button>
<!-- <el-button type="primary" @click="handleCopy(currentItem.content)">复制文案</el-button> -->
<!-- 保存 -->
<!-- <el-button type="primary" @click="handleSave">保存</el-button> -->
</template>
</el-dialog>
<el-button
v-show="showScrollToTop"
type="primary"
@click="scrollToTop"
class="fixed bottom-4 right-4 z-10 transition-all duration-300"
:icon="ArrowUp"
circle
></el-button>
</template>
<script setup lang="ts">
import {
ref,
reactive,
onMounted,
onBeforeUnmount,
nextTick,
computed,
} from "vue";
import { useRoute } from "vue-router";
import { debounce, throttle } from "lodash-es"; // 记得安装lodash-es
import { showLoading, hiddenLoading } from "@/utils/loading";
import { showMsg } from "@/utils/showMsg";
// Element Plus 图标 + 组件
import { Refresh, ArrowLeft, Search, ArrowUp } from "@element-plus/icons-vue";
// 接口请求
import apis from "@/api/index";
// 子组件
import WaterfallFlow from "./waterfallFlow.vue";
// import DictTag from "@/components/DictTag/index.vue";
// 静态资源 & 工具函数
import {
DictOption,
TaskInfo,
WaterfallItem,
// MediaType,
WaterfallProps,
} from "@/typings/work";
import defaultImg from "@/assets/images/img-load-err.png";
import fallbackCopyToClipboard from "@/utils/copyText";
import { LazyImg, Waterfall } from "vue-waterfall-plugin-next";
import "vue-waterfall-plugin-next/dist/style.css";
enum MediaType {
IMAGE = 1, // 纯图片
IMAGE_TEXT = 2, // 图文
VIDEO = 3, // 视频
TEXT = 4, // 纯文字
}
const waterfallKey = ref(0);
// ✅ 自定义响应式断点配置
const waterfallBreakpoints = {
// 1200: { rowPerView: 4 }, // 宽度 < 1200px4列
// 900: { rowPerView: 3 }, // 宽度 < 900px3列
600: { rowPerView: 2 }, // 宽度 < 600px2列(平板横屏)
320: { rowPerView: 2 }, // 宽度 < 320px2列(所有手机竖屏)
};
const searchForm = ref({
title: "",
// type: 1, //默认图片
});
const search = async () => {
currentPage.value = 1;
hasMore.value = true;
scrollContainer.value.scrollTop = 0;
await getTaskProductionListLimit();
};
const debouncedSearch = debounce(search, 300);
const { WOEKS_LIST_TWO } = apis;
// ===================== 1. 路由实例(Vue3 替代 this.$route =====================
const route = useRoute();
const taskId = route.params.taskId as string;
// 复用枚举:1图片 2图文 3视频 4文字
// ===================== 2. TS 类型定义 =====================
/** 任务基础信息 */
// ===================== 3. 全局响应式数据 =====================
// 字典类型列表
const taskTypes = ref<DictOption[]>(
JSON.parse(sessionStorage.getItem("task_type") || "[]")
);
// 任务详情
const taskInfo = ref<TaskInfo>(
JSON.parse(sessionStorage.getItem("currentTask") || "{}")
);
const currentTaskType = computed(() => Number(taskInfo.value.type || 1));
// 瀑布流列表
const list = ref<WaterfallItem[]>([]);
// 抽屉显隐
const visible = ref(false);
// 当前选中的卡片项
const currentItem = ref<WaterfallItem | null>(null);
// 分页 & 加载状态
const currentPage = ref(1);
const pageSize = computed(() => (isMobile.value ? 20 : 50));
const isLoading = ref(false);
const hasMore = ref(true);
const cardDom = computed(
() => document.getElementsByClassName("waterfall-item")[0]
);
const cardDomWidth = computed(() => cardDom.value?.offsetWidth ?? 200);
// 计算高度方法
const calculateHeight = (width: number, height: number) => {
// console.log(11111111111111, cardDom, cardDomWidth.value, width, height);
// 16*200/9=
return (cardDomWidth.value * height) / width || 356;
};
// 滚动容器 DOM 引用(标注 TS 类型)
const scrollContainer = ref<HTMLDivElement | null>(null);
// ===================== 4. 工具/业务方法 =====================
/** 返回上一页 */
const goBack = () => {
sessionStorage.clear();
window.history.go(-1);
};
/** 返回顶部 */
const scrollToTop = () => {
scrollContainer.value?.scrollTo({
top: 0,
behavior: "smooth",
});
};
/** 刷新列表 */
const refresh = () => {
currentPage.value = 1;
list.value = [];
hasMore.value = true;
getTaskProductionListLimit();
};
/** 图片加载错误兜底 */
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement;
target.src = defaultImg;
};
/** 分页加载作品列表(无限滚动核心) */
const getTaskProductionListLimit = async () => {
// 防重复请求
if (isLoading.value || !hasMore.value) return;
isLoading.value = true;
try {
currentPage.value == 1 && showLoading();
const res = await WOEKS_LIST_TWO({
// taskId,
current: currentPage.value,
size: pageSize.value,
...searchForm.value,
});
const { records = [] } = res || {};
records.forEach((item) => {
let sizes = [
[9, 16],
[1, 1],
[16, 9],
[3, 4],
[4, 3],
[2, 3],
[3, 2],
];
if (!item.width || !item.height) {
let size = sizes[Math.floor(Math.random() * sizes.length)];
item.width = size[0];
item.height = size[1];
}
});
if (currentPage.value == 1) {
list.value = records;
} else {
list.value = list.value.concat(records);
}
// 判断是否还有更多数据
hasMore.value = !(records.length < pageSize.value);
} catch (error) {
console.error("列表加载失败:", error);
// 加载失败,页码回退
currentPage.value = Math.max(1, currentPage.value - 1);
} finally {
isLoading.value = false;
if (currentPage.value == 1 && !isFirstLoad.value) {
hiddenLoading();
}
}
};
const showScrollToTop = ref(false);
/** 滚动触底加载 */
const handleScroll = throttle(() => {
const container = scrollContainer.value;
if (!container || isLoading.value || !hasMore.value) return;
const { scrollHeight, scrollTop, clientHeight } = container;
// 距离底部 50px 预加载
if (scrollTop + clientHeight >= scrollHeight - 50) {
currentPage.value++;
getTaskProductionListLimit();
}
if (scrollTop > 1000) {
showScrollToTop.value = true;
} else {
showScrollToTop.value = false;
}
}, 100);
// 检测师手机端还是pc端,不要宽度判断,用userAgent判断
const isMobile = computed(() => {
return navigator.userAgent.includes("Mobile");
});
/** 瀑布流卡片点击 */
const handleCardClick = (item: WaterfallItem) => {
// if (isMobile.value) {
// } else {
visible.value = true;
currentItem.value = item;
console.log("点击作品:", item);
// }
};
const saving = ref(false);
/** 抽屉关闭 */
const handleClose = () => {
visible.value = false;
// currentItem.value = null;
};
/**
* ✅ 纯前端保存功能(无后端接口)
* 支持:图片/视频/纯文字三种类型
*/
const handleSave = async () => {
if (!currentItem.value) {
showMsg("请先选择要保存的作品");
return;
}
if (saving.value) return; // 防止重复点击
saving.value = true;
try {
const item = currentItem.value;
// 生成文件名:标题_时间戳.后缀(避免重复)
const timestamp = Date.now();
const safeTitle =
item.title?.replace(/[<>:"/\\|?*]/g, "_") || `work_${item.id}`;
switch (currentTaskType.value) {
// 图片类型:Canvas转Blob下载(解决跨域问题)
case MediaType.IMAGE:
case MediaType.IMAGE_TEXT:
await saveImage(item.fileUrl, `${safeTitle}_${timestamp}.png`);
break;
// 视频类型:Fetch获取Blob下载
case MediaType.VIDEO:
await saveVideo(item.fileUrl, `${safeTitle}_${timestamp}.mp4`);
break;
// 纯文字类型:生成TXT文件下载
case MediaType.TEXT:
saveText(item.content, `${safeTitle}_${timestamp}.txt`);
break;
default:
showMsg("不支持的文件类型");
break;
}
showMsg("保存成功!", "success");
handleClose();
} catch (error) {
console.error("保存失败:", error);
showMsg(error.message || "保存失败,请检查网络连接或稍后重试", "error");
} finally {
saving.value = false;
}
};
/**
* 保存图片(解决跨域问题)
* @param url 图片URL
* @param fileName 保存的文件名
*/
const saveImage = (url: string, fileName: string): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // 关键:允许跨域
img.onload = () => {
try {
// 创建Canvas
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas不支持"));
return;
}
// 绘制图片
ctx.drawImage(img, 0, 0);
// 转Blob并下载
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("图片转换失败"));
return;
}
downloadBlob(blob, fileName);
resolve();
}, "image/png");
} catch (e) {
reject(new Error("图片处理失败,可能是跨域限制"));
}
};
img.onerror = () => {
reject(new Error("图片加载失败"));
};
img.src = url;
});
};
/**
* 保存视频
* @param url 视频URL
* @param fileName 保存的文件名
*/
const saveVideo = async (url: string, fileName: string): Promise<void> => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`视频加载失败:${response.status}`);
}
const blob = await response.blob();
downloadBlob(blob, fileName);
} catch (error) {
throw new Error("视频下载失败,可能是文件过大或网络问题");
}
};
/**
* 保存纯文字为TXT文件
* @param text 文字内容
* @param fileName 保存的文件名
*/
const saveText = (text: string, fileName: string) => {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
downloadBlob(blob, fileName);
};
/**
* 通用Blob下载方法
* @param blob 要下载的Blob对象
* @param fileName 保存的文件名
*/
const downloadBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url); // 释放内存
};
/** 复制文案 */
const handleCopy = (text: string) => {
fallbackCopyToClipboard(text);
};
// 是否首次加载
const isFirstLoad = ref(true);
// ===================== 5. 生命周期 =====================
/** 组件挂载:初始化所有数据 + 绑定滚动事件 */
onMounted(async () => {
// 纯文字类型,修改每页条数
// 重置分页 + 加载第一页
currentPage.value = 1;
hasMore.value = true;
await getTaskProductionListLimit();
// 绑定滚动事件
await nextTick();
const container = scrollContainer.value;
if (container) {
container.addEventListener("scroll", handleScroll);
}
setTimeout(() => {
isFirstLoad.value = false;
waterfallKey.value++;
hiddenLoading();
}, 500);
});
/** 组件卸载:解绑滚动事件,防止内存泄漏 */
onBeforeUnmount(() => {
const container = scrollContainer.value;
if (container) {
container.removeEventListener("scroll", handleScroll);
}
});
</script>
<style scoped lang="scss">
.task-production-detail {
max-height: 100vh;
overflow: hidden;
.task-production-detail-header {
overflow-y: auto;
background-color: #0072fed6;
// border-bottom: 1px solid #e5e5e5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
// text-align: center;
// background-color: #0072fe;
> div {
max-width: 300px;
:deep(.el-input) {
// width: 100%;
flex: 1;
}
}
}
.card-scroll-container {
max-height: calc(100vh - 60px);
overflow-y: auto;
}
}
.loading-tip {
text-align: center;
padding: 0 0 20px;
font-size: 14px;
color: #999;
}
.detail-content {
h3 {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
img,
video {
width: 100%;
height: auto;
display: block;
object-fit: contain;
transition: transform 0.3s ease;
}
}
/* Vue3 推荐使用 :deep() 替代 ::v-deep,两种都兼容 */
:deep(.el-drawer__title) {
margin-bottom: 0 !important;
}
span {
text-align: left;
}
.card-item {
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.829);
}
img {
&:hover {
transform: scale(1.05);
transition: transform 0.3s ease;
}
}
}
</style>
+286
View File
@@ -0,0 +1,286 @@
import { showMsg } from "@/utils/showMsg";
/**
* 全环境通用文件保存工具
* 支持:PC浏览器、手机浏览器、uniapp webview
* 支持文件类型:图片、视频、纯文字
*/
// 文件类型枚举
export const FileType = {
IMAGE: 'image',
VIDEO: 'video',
TEXT: 'text'
}
/**
* 检测是否在uniapp webview环境中
*/
const isUniAppWebview = () => {
return typeof uni !== 'undefined'
&& typeof uni.downloadFile === 'function'
&& typeof uni.saveImageToPhotosAlbum === 'function'
}
/**
* 检测是否是iOS设备
*/
const isIOS = () => {
return /iPhone|iPad|iPod/i.test(navigator.userAgent)
}
/**
* 通用保存文件入口(所有环境通用)
* @param {Object} options 保存选项
* @param {string} options.type 文件类型:FileType.IMAGE / FileType.VIDEO / FileType.TEXT
* @param {string} options.url 文件URL(图片/视频必填)
* @param {string} options.content 文字内容(文字类型必填)
* @param {string} options.fileName 保存的文件名(可选,自动生成)
* @returns {Promise<void>}
*/
export const saveFile = async (options) => {
const { type, url, content, fileName } = options
// 参数校验
if (!type) {
throw new Error('请指定文件类型')
}
if ((type === FileType.IMAGE || type === FileType.VIDEO) && !url) {
throw new Error('请提供文件URL')
}
if (type === FileType.TEXT && !content) {
throw new Error('请提供文字内容')
}
// 生成默认文件名
const timestamp = Date.now()
const defaultFileName = `file_${timestamp}`
const finalFileName = fileName || defaultFileName
// 自动选择保存方式
if (isUniAppWebview()) {
await saveInUniApp(type, url, content, finalFileName)
} else {
await saveInBrowser(type, url, content, finalFileName)
}
}
/**
* uniapp webview环境保存
*/
const saveInUniApp = async (type, url, content, fileName) => {
switch (type) {
case FileType.IMAGE:
await uniSaveImage(url)
break
case FileType.VIDEO:
await uniSaveVideo(url)
break
case FileType.TEXT:
// webview无法直接保存文件,降级为复制到剪贴板
await uniCopyText(content)
break
default:
throw new Error('不支持的文件类型')
}
}
/**
* 浏览器环境保存
*/
const saveInBrowser = async (type, url, content, fileName) => {
switch (type) {
case FileType.IMAGE:
await browserSaveImage(url, fileName)
break
case FileType.VIDEO:
await browserSaveVideo(url, fileName)
break
case FileType.TEXT:
browserSaveText(content, fileName)
break
default:
throw new Error('不支持的文件类型')
}
}
// ==================== uniapp保存方法 ====================
const uniSaveImage = (url) => {
return new Promise((resolve, reject) => {
uni.showLoading({ title: '保存中...' })
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error('图片下载失败'))
return
}
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => resolve(),
fail: (err) => {
if (err.errMsg.includes('auth')) {
reject(new Error('请在设置中开启相册权限后重试'))
} else if (err.errMsg.includes('cancel')) {
reject(new Error('已取消保存'))
} else {
reject(new Error('保存图片失败'))
}
}
})
},
fail: () => reject(new Error('网络请求失败,请检查网络连接')),
complete: () => uni.hideLoading()
})
})
}
const uniSaveVideo = (url) => {
return new Promise((resolve, reject) => {
uni.showLoading({ title: '保存中...' })
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error('视频下载失败'))
return
}
uni.saveVideoToPhotosAlbum({
filePath: res.tempFilePath,
success: () => resolve(),
fail: (err) => {
if (err.errMsg.includes('auth')) {
reject(new Error('请在设置中开启相册权限后重试'))
} else if (err.errMsg.includes('cancel')) {
reject(new Error('已取消保存'))
} else {
reject(new Error('保存视频失败'))
}
}
})
},
fail: () => reject(new Error('网络请求失败,请检查网络连接')),
complete: () => uni.hideLoading()
})
})
}
const uniCopyText = (text) => {
return new Promise((resolve, reject) => {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({ title: '文案已复制到剪贴板', icon: 'success' })
resolve()
},
fail: () => reject(new Error('复制失败'))
})
})
}
// ==================== 浏览器保存方法 ====================
const browserSaveImage = (url, fileName) => {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
// Canvas不支持,降级为打开新标签页
openInNewTab(url)
resolve()
return
}
ctx.drawImage(img, 0, 0)
canvas.toBlob((blob) => {
if (!blob) {
openInNewTab(url)
resolve()
return
}
downloadBlob(blob, `${fileName}.png`)
resolve()
}, 'image/png')
} catch (e) {
// 发生错误,降级为打开新标签页
openInNewTab(url)
resolve()
}
}
img.onerror = () => {
openInNewTab(url)
resolve()
}
img.src = url
})
}
const browserSaveVideo = async (url, fileName) => {
try {
// iOS Safari不支持Blob下载视频,直接打开新标签页
if (isIOS()) {
openInNewTab(url)
return
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(`视频加载失败:${response.status}`)
}
const blob = await response.blob()
downloadBlob(blob, `${fileName}.mp4`)
} catch (error) {
// 发生错误,降级为打开新标签页
openInNewTab(url)
}
}
const browserSaveText = (text, fileName) => {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
downloadBlob(blob, `${fileName}.txt`)
}
// ==================== 通用工具方法 ====================
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showMsg("保存成功");
}
const openInNewTab = (url) => {
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.rel = 'noopener noreferrer'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
// 导出默认对象,方便使用
export default {
saveFile,
FileType
}