Files
tydata-webview-v2/src/components/QRcode.vue
2025-10-30 13:34:28 +08:00

645 lines
19 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>
<van-popup v-model:show="show" round position="bottom" :style="{ maxHeight: '95vh' }">
<div class="qrcode-popup-container">
<div class="qrcode-content">
<van-swipe class="poster-swiper rounded-lg sm:rounded-xl shadow" indicator-color="white"
@change="onSwipeChange">
<van-swipe-item v-for="(_, index) in posterImages" :key="index">
<canvas :ref="(el) => (posterCanvasRefs[index] = el)"
class="poster-canvas rounded-lg sm:rounded-xl m-auto"></canvas>
</van-swipe-item>
</van-swipe>
</div>
<div v-if="mode === 'promote'"
class="swipe-tip text-center text-gray-700 text-xs sm:text-sm mb-1 sm:mb-2 px-2">
<span class="swipe-icon"></span> 左右滑动切换海报
<span class="swipe-icon"></span>
</div>
<van-divider class="my-2 sm:my-3">分享到好友</van-divider>
<div class="flex items-center justify-around pb-3 sm:pb-4 px-4">
<!-- 微信环境显示分享保存和复制按钮 -->
<template v-if="isWeChat">
<!-- <div class="flex flex-col items-center justify-center cursor-pointer" @click="shareToFriend">
<img src="@/assets/images/icon_share_friends.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
分享给好友
</div>
</div>
<div class="flex flex-col items-center justify-center cursor-pointer" @click="shareToTimeline">
<img src="@/assets/images/icon_share_wechat.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
分享到朋友圈
</div>
</div> -->
<div class="flex flex-col items-center justify-center cursor-pointer" @click="savePosterForWeChat">
<img src="@/assets/images/icon_share_img.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
保存图片
</div>
</div>
<div class="flex flex-col items-center justify-center cursor-pointer" @click="copyUrl">
<img src="@/assets/images/icon_share_url.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
复制链接
</div>
</div>
</template>
<!-- 非微信环境显示保存和复制按钮 -->
<template v-else>
<div class="flex flex-col items-center justify-center cursor-pointer" @click="savePoster">
<img src="@/assets/images/icon_share_img.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
保存图片
</div>
</div>
<div class="flex flex-col items-center justify-center cursor-pointer" @click="copyUrl">
<img src="@/assets/images/icon_share_url.svg"
class="share-icon w-9 h-9 sm:w-10 sm:h-10 rounded-full" />
<div class="text-center mt-1 text-gray-600 text-xs">
复制链接
</div>
</div>
</template>
</div>
</div>
</van-popup>
<!-- 图片保存指引遮罩层 -->
<ImageSaveGuide :show="showImageGuide" :image-url="currentImageUrl" :title="imageGuideTitle"
@close="closeImageGuide" />
</template>
<script setup>
import { ref, watch, nextTick, computed, onMounted, toRefs } from "vue";
import QRCode from "qrcode";
import { showToast } from "vant";
import { useWeixinShare } from "@/composables/useWeixinShare";
import ImageSaveGuide from "./ImageSaveGuide.vue";
const props = defineProps({
linkIdentifier: {
type: String,
required: true,
},
mode: {
type: String,
default: "promote", // 例如 "promote" | "invitation"
},
});
const { linkIdentifier, mode } = toRefs(props);
const posterCanvasRefs = ref([]); // 用于绘制海报的canvas数组
const currentIndex = ref(0); // 当前显示的海报索引
const postersGenerated = ref([]); // 标记海报是否已经生成过将在onMounted中初始化
const show = defineModel("show");
// 微信环境检测
const isWeChat = computed(() => {
return /MicroMessenger/i.test(navigator.userAgent);
});
// 微信分享功能
const { configWeixinShare } = useWeixinShare();
// 图片保存指引遮罩层相关
const showImageGuide = ref(false);
const currentImageUrl = ref('');
const imageGuideTitle = ref('');
const url = computed(() => {
const baseUrl = window.location.origin; // 获取当前站点的域名
return mode.value === "promote"
? `${baseUrl}/agent/promotionInquire/` // 使用动态的域名
: `${baseUrl}/agent/invitationAgentApply/`;
});
// 海报图片数组
const posterImages = ref([]);
// QR码位置配置为每个海报单独配置
const qrCodePositions = ref({
// promote模式的配置 (tg_qrcode)
promote: [
{ x: 138, y: 954, size: 220 }, // tg_qrcode_1.png
{ x: 138, y: 954, size: 220 }, // tg_qrcode_2.jpg
{ x: 138, y: 954, size: 220 }, // tg_qrcode_3.jpg
],
// invitation模式的配置 (yq_qrcode)
invitation: [
{ x: 138, y: 954, size: 220 }, // yq_qrcode_1.png
],
});
// 处理轮播图切换事件
const onSwipeChange = (index) => {
currentIndex.value = index;
if (!postersGenerated.value[index]) {
generatePoster(index);
}
};
// 加载海报图片
const loadPosterImages = async () => {
const images = [];
const basePrefix = mode.value === "promote" ? "tg_qrcode_" : "yq_qrcode_";
// 根据模式确定要加载的图片编号
const imageNumbers = mode.value === "promote" ? [1, 2, 3] : [1];
// 加载图片
for (const i of imageNumbers) {
// 尝试加载 .png 文件
try {
const module = await import(
`@/assets/images/${basePrefix}${i}.png`
);
images.push(module.default);
continue; // 如果成功加载了 png则跳过后续的 jpg 尝试
} catch (error) {
console.warn(
`Image ${basePrefix}${i}.png not found, trying jpg...`
);
}
// 如果 .png 不存在,尝试加载 .jpg 文件
try {
const module = await import(
`@/assets/images/${basePrefix}${i}.jpg`
);
images.push(module.default);
} catch (error) {
console.warn(
`Image ${basePrefix}${i}.jpg not found either, using fallback.`
);
if (i === 1) {
// 如果第一张也不存在,创建一个空白图片
const emptyImg = new Image();
emptyImg.width = 600;
emptyImg.height = 800;
images.push(emptyImg.src);
} else if (images.length > 0) {
images.push(images[0]);
}
}
}
return images;
};
onMounted(async () => {
posterImages.value = await loadPosterImages();
// 根据加载的图片数量初始化postersGenerated数组
postersGenerated.value = Array(posterImages.value.length).fill(false);
});
// 生成海报并合成二维码
const generatePoster = async (index) => {
// 如果已经生成过海报,就直接返回
if (postersGenerated.value[index]) return;
// 确保 DOM 已经渲染完成
await nextTick();
const canvas = posterCanvasRefs.value[index];
if (!canvas) return; // 如果 canvas 元素为空则直接返回
const ctx = canvas.getContext("2d");
// 1. 加载海报图片
const posterImg = new Image();
posterImg.src = posterImages.value[index];
posterImg.onload = () => {
// 设置 canvas 尺寸与海报图一致
canvas.width = posterImg.width;
canvas.height = posterImg.height;
// 2. 绘制海报图片
ctx.drawImage(posterImg, 0, 0);
// 3. 生成二维码
QRCode.toDataURL(
generalUrl(),
{ width: 150, margin: 0 },
(err, qrCodeUrl) => {
if (err) {
console.error(err);
return;
}
// 4. 加载二维码图片
const qrCodeImg = new Image();
qrCodeImg.src = qrCodeUrl;
qrCodeImg.onload = () => {
// 获取当前海报的二维码位置配置
const positions = qrCodePositions.value[mode.value];
const position = positions[index] || positions[0]; // 如果没有对应索引的配置,则使用第一个配置
// 计算Y坐标负值表示从底部算起的位置
const qrY =
position.y < 0
? posterImg.height + position.y
: position.y;
// 绘制二维码
ctx.drawImage(
qrCodeImg,
position.x,
qrY,
position.size,
position.size
);
// 标记海报已生成
postersGenerated.value[index] = true;
};
}
);
};
};
// 监听 show 变化show 为 true 时生成海报
watch(show, (newVal) => {
if (newVal && !postersGenerated.value[currentIndex.value]) {
generatePoster(currentIndex.value); // 当弹窗显示且当前海报未生成时生成海报
}
});
// 分享给好友
const shareToFriend = () => {
if (!isWeChat.value) {
showToast({ message: "请在微信中打开" });
return;
}
const shareUrl = generalUrl();
const shareConfig = {
title: mode.value === "promote"
? "天远数据 - 推广链接"
: "天远数据 - 邀请链接",
desc: mode.value === "promote"
? "扫码查看天远数据推广信息"
: "扫码申请天远数据代理权限",
link: shareUrl,
imgUrl: "https://www.tianyuandb.com/logo.jpg"
};
configWeixinShare(shareConfig);
// 显示分享指引
showShareGuide("好友");
};
// 分享到朋友圈
const shareToTimeline = () => {
if (!isWeChat.value) {
showToast({ message: "请在微信中打开" });
return;
}
const shareUrl = generalUrl();
const shareConfig = {
title: mode.value === "promote"
? "天远数据 - 推广链接"
: "天远数据 - 邀请链接",
desc: mode.value === "promote"
? "扫码查看天远数据推广信息"
: "扫码申请天远数据代理权限",
link: shareUrl,
imgUrl: "https://www.tianyuandb.com/logo.jpg"
};
configWeixinShare(shareConfig);
// 显示分享指引
showShareGuide("朋友圈");
};
// 显示分享指引
const showShareGuide = (target) => {
// 设置遮罩层内容
currentImageUrl.value = ''; // 分享指引不需要图片
imageGuideTitle.value = `分享到${target}`;
// 显示遮罩层
showImageGuide.value = true;
};
// 微信环境保存图片
const savePosterForWeChat = () => {
const canvas = posterCanvasRefs.value[currentIndex.value];
const dataURL = canvas.toDataURL("image/png");
// 设置遮罩层内容
currentImageUrl.value = dataURL;
imageGuideTitle.value = '保存图片到相册';
// 显示遮罩层
showImageGuide.value = true;
};
// 关闭图片保存指引
const closeImageGuide = () => {
showImageGuide.value = false;
};
// 保存海报图片 - 多种保存方式(非微信环境)
const savePoster = () => {
const canvas = posterCanvasRefs.value[currentIndex.value];
const dataURL = canvas.toDataURL("image/png");
// 检测环境
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
// 手机浏览器环境
saveForMobile(dataURL);
} else {
// PC浏览器环境
saveForPC(dataURL);
}
};
// PC浏览器保存方式
const saveForPC = (dataURL) => {
const a = document.createElement("a");
a.href = dataURL;
a.download = "天远数据海报.png";
a.click();
};
// 手机浏览器保存方式
const saveForMobile = async (dataURL) => {
// 方法1: 尝试使用 File System Access API (Chrome 86+)
const fileSystemSuccess = await saveWithFileSystemAPI(dataURL);
if (fileSystemSuccess) return;
// 方法2: 尝试使用 Blob 和 URL.createObjectURL
try {
const blob = dataURLToBlob(dataURL);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "天远数据海报.png";
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 清理 URL 对象
setTimeout(() => URL.revokeObjectURL(url), 100);
showToast({ message: "图片已保存到相册" });
} catch (error) {
console.error("Blob保存失败:", error);
// 方法3: 尝试使用 share API (支持分享到其他应用)
const shareSuccess = await tryShareAPI(dataURL);
if (!shareSuccess) {
// 方法4: 降级到长按保存提示
showLongPressTip(dataURL);
}
}
};
// 显示长按保存提示(非微信环境使用)
const showLongPressTip = (dataURL) => {
// 设置遮罩层内容
currentImageUrl.value = dataURL;
imageGuideTitle.value = '保存图片到相册';
// 显示遮罩层
showImageGuide.value = true;
};
// 将 dataURL 转换为 Blob
const dataURLToBlob = (dataURL) => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
// 备用保存方法 - 使用 File System Access API (现代浏览器)
const saveWithFileSystemAPI = async (dataURL) => {
if ('showSaveFilePicker' in window) {
try {
const blob = dataURLToBlob(dataURL);
const fileHandle = await window.showSaveFilePicker({
suggestedName: '天远数据海报.png',
types: [{
description: 'PNG images',
accept: { 'image/png': ['.png'] }
}]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
showToast({ message: "图片已保存" });
return true;
} catch (error) {
console.error("File System API 保存失败:", error);
return false;
}
}
return false;
};
// 尝试使用 Share API
const tryShareAPI = async (dataURL) => {
if (navigator.share && navigator.canShare) {
try {
const blob = dataURLToBlob(dataURL);
const file = new File([blob], '天远数据海报.png', { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
await navigator.share({
title: '天远数据海报',
text: '分享海报图片',
files: [file]
});
showToast({ message: "图片已分享" });
return true;
}
} catch (error) {
console.error("Share API 失败:", error);
}
}
return false;
};
const generalUrl = () => {
return url.value + encodeURIComponent(linkIdentifier.value);
};
const copyUrl = () => {
copyToClipboard(generalUrl());
};
// 复制链接
const copyToClipboard = (text) => {
if (navigator.clipboard && window.isSecureContext) {
// 支持 Clipboard API
navigator.clipboard
.writeText(text)
.then(() => {
showToast({ message: "链接已复制!" });
})
.catch((err) => {
console.error("复制失败:", err);
});
} else {
// 对于不支持 Clipboard API 的浏览器,使用 fallback 方法
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
showToast({ message: "链接已复制!" });
} catch (err) {
console.error("复制失败:", err);
} finally {
document.body.removeChild(textArea);
}
}
};
</script>
<style lang="scss" scoped>
.qrcode-popup-container {
display: flex;
flex-direction: column;
max-height: 95vh;
overflow: hidden;
}
.qrcode-content {
flex-shrink: 0;
padding: 0.75rem;
}
/* 小屏设备优化 */
@media (max-width: 375px) {
.qrcode-content {
padding: 0.5rem;
}
}
/* 中等及以上屏幕 */
@media (min-width: 640px) {
.qrcode-content {
padding: 1rem;
}
}
.poster-swiper {
height: calc(95vh - 180px);
min-height: 300px;
max-height: 500px;
width: 100%;
}
/* 小屏设备:更小的海报高度 */
@media (max-width: 375px) {
.poster-swiper {
height: calc(95vh - 160px);
min-height: 280px;
max-height: 400px;
}
}
/* 中等屏幕 */
@media (min-width: 640px) and (max-width: 767px) {
.poster-swiper {
height: calc(95vh - 190px);
max-height: 520px;
}
}
/* 大屏幕 */
@media (min-width: 768px) {
.poster-swiper {
height: calc(95vh - 200px);
max-height: 600px;
}
}
.poster-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
.swipe-tip {
animation: fadeInOut 2s infinite;
flex-shrink: 0;
}
.swipe-icon {
display: inline-block;
animation: slideLeftRight 1.5s infinite;
font-size: 14px;
}
@media (min-width: 640px) {
.swipe-icon {
font-size: 16px;
}
}
.share-icon {
transition: transform 0.2s ease;
}
.share-icon:active {
transform: scale(0.95);
}
@keyframes fadeInOut {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@keyframes slideLeftRight {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
/* 优化 van-divider 在小屏幕上的间距 */
:deep(.van-divider) {
margin: 0.5rem 0;
}
@media (min-width: 640px) {
:deep(.van-divider) {
margin: 0.75rem 0;
}
}
</style>