Files
work-show-web/src/views/detail609.vue
T
2026-06-11 10:26:53 +08:00

632 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>