Files
----/前端源码/uni-app/components/SharePosterModal.vue

598 lines
15 KiB
Vue
Raw Permalink 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>
<view class="share-poster-modal">
<view v-if="visible && !showPosterPreview" class="modal-overlay" @click="handleClose">
<view class="modal-box" @click.stop>
<!-- 关闭按钮 -->
<view class="close-btn" @click="handleClose">
<text class="close-icon">×</text>
</view>
<!-- 海报预览 -->
<view class="poster-preview">
<view class="poster-header">
<text class="poster-symbol"></text>
<text class="poster-title">易凡起名</text>
<text class="poster-subtitle">传承国学智慧 · 赋予美好寓意</text>
</view>
<view class="poster-card">
<view class="service-list">
<view class="service-item" v-for="item in services" :key="item">
<view class="service-dot"></view>
<text class="service-text">{{ item }}</text>
</view>
</view>
<view class="poster-divider"></view>
<view class="qr-area">
<view class="qr-box">
<image v-if="qrImageUrl" :src="qrImageUrl" class="qr-image" mode="aspectFit" />
</view>
<text class="invite-text">邀请码{{ userId }}</text>
</view>
</view>
<text class="poster-footer"> 邀您共探姓名玄机 </text>
</view>
<!-- 按钮 -->
<view class="btn-row">
<button class="btn btn-share" open-type="share">
<text class="btn-label">分享好友</text>
</button>
<view class="btn btn-save" @click="handleSave">
<text class="btn-label">{{ isSaving ? '生成中...' : '保存图片' }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 海报图片预览直接展示 -->
<view v-if="showPosterPreview" class="poster-save-overlay" @click="handleClose">
<view class="poster-save-tip">{{ isSaving ? '海报生成中...' : '海报已生成,可保存到本地' }}</view>
<img v-if="posterDataUrl" :src="posterDataUrl" class="poster-save-image" @click.stop />
<view v-else class="poster-save-loading">海报生成中...</view>
<view class="poster-save-actions" @click.stop>
<view class="poster-save-btn" :class="{ 'poster-save-btn-disabled': !posterDataUrl || isSaving }" @click="handleSave">
{{ isSaving ? '生成中...' : '保存图片' }}
</view>
<view class="poster-save-close" @click="handleClose">关闭</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
// @ts-ignore
import QRCode from '@/utils/qrcode.js';
const props = defineProps<{
visible: boolean;
userId: number;
}>();
const emit = defineEmits<{
close: [];
}>();
const services = ['个人起名', '公司起名', '专业测名', '择吉日'];
const qrModules = ref<number[][]>([]);
const qrImageUrl = ref('');
const isSaving = ref(false);
const posterDataUrl = ref('');
const showPosterPreview = ref(false);
const QR_SIZE = 400;
const POSTER_WIDTH = 300;
const POSTER_HEIGHT = 500;
const POSTER_SCALE = 2;
const qrUrl = computed(() => `https://yfh5.action-ai.cn?invite_id=${encodeURIComponent(String(props.userId || ''))}`);
watch(() => props.visible, async (val) => {
if (val) {
posterDataUrl.value = '';
qrImageUrl.value = '';
showPosterPreview.value = true;
await nextTick();
await preparePoster();
} else {
showPosterPreview.value = false;
}
});
const createCanvas = (width: number, height: number): HTMLCanvasElement => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
const generateQrModules = (): number[][] => {
const result = QRCode.generate(qrUrl.value, { errorCorrectionLevel: 'M' });
const modules = result.modules || [];
qrModules.value = modules;
return modules;
};
const drawQrModulesToCanvas = (
ctx: CanvasRenderingContext2D,
modules: number[][],
x: number,
y: number,
size: number
) => {
const count = modules.length;
if (!count) return;
const cell = size / count;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(x, y, size, size);
ctx.fillStyle = '#000000';
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (modules[r][c]) ctx.fillRect(x + c * cell, y + r * cell, Math.ceil(cell), Math.ceil(cell));
}
}
};
const generateQrImage = async (): Promise<string> => {
qrImageUrl.value = '';
const modules = generateQrModules();
if (!modules.length) {
throw new Error('qrcode modules empty');
}
const canvas = createCanvas(QR_SIZE, QR_SIZE);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('qrcode canvas context unavailable');
}
drawQrModulesToCanvas(ctx, modules, 0, 0, QR_SIZE);
qrImageUrl.value = canvas.toDataURL('image/png');
return qrImageUrl.value;
};
const handleClose = () => {
showPosterPreview.value = false;
emit('close');
};
// H5专用loading/toast
const showLoading = (title: string) => {
if (typeof uni?.showLoading === 'function') {
uni.showLoading({ title });
}
};
const hideLoading = () => {
if (typeof uni?.hideLoading === 'function') {
uni.hideLoading();
}
};
const showToast = (opts: { title: string; icon?: string }) => {
if (typeof uni?.showToast === 'function') {
uni.showToast(opts);
} else {
alert(opts.title);
}
};
// 绘制圆角矩形
const drawRoundRect = (ctx: any, x: number, y: number, w: number, h: number, r: number) => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
const drawImage = (
ctx: CanvasRenderingContext2D,
src: string,
x: number,
y: number,
w: number,
h: number
) => {
return new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, x, y, w, h);
resolve();
};
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
};
const triggerDownload = (src: string, fileName: string) => {
try {
const link = document.createElement('a');
if (typeof link.download !== 'string') {
return false;
}
link.href = src;
link.download = fileName;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return true;
} catch (e) {
return false;
}
};
const buildPosterDataUrl = async (qrSrc: string) => {
const width = POSTER_WIDTH * POSTER_SCALE;
const height = POSTER_HEIGHT * POSTER_SCALE;
const posterCanvas = createCanvas(width, height);
const ctx = posterCanvas.getContext('2d');
if (!ctx) {
throw new Error('poster canvas context unavailable');
}
ctx.scale(POSTER_SCALE, POSTER_SCALE);
const w = POSTER_WIDTH;
const h = POSTER_HEIGHT;
// 1. 背景
ctx.fillStyle = '#2d1515';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#d4af37';
ctx.fillRect(0, 0, w, 5);
// 2. 标题区
ctx.textAlign = 'center';
ctx.fillStyle = '#d4af37';
ctx.font = 'bold 28px serif';
ctx.fillText('☯', w / 2, 45);
ctx.fillStyle = '#f2e6d8';
ctx.font = 'bold 22px serif';
ctx.fillText('易凡起名', w / 2, 80);
ctx.fillStyle = '#d4af37';
ctx.font = '12px sans-serif';
ctx.fillText('传承国学智慧 · 赋予美好寓意', w / 2, 100);
// 3. 卡片
const cardX = 20;
const cardY = 115;
const cardW = w - 40;
const cardH = 310;
ctx.fillStyle = '#fffdf9';
drawRoundRect(ctx, cardX, cardY, cardW, cardH, 8);
ctx.fill();
// 4. 服务项目
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
services.forEach((name, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = cardX + 25 + col * 115;
const y = cardY + 32 + row * 26;
ctx.fillStyle = '#8b2323';
ctx.beginPath();
ctx.arc(x - 8, y - 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#333';
ctx.fillText(name, x, y);
});
// 5. 分隔线
const lineY = cardY + 85;
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cardX + 15, lineY);
ctx.lineTo(cardX + cardW - 15, lineY);
ctx.stroke();
// 6. 二维码
const qrSize = 120;
const qrX = (w - qrSize) / 2;
const qrY = lineY + 15;
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2;
ctx.strokeRect(qrX - 5, qrY - 5, qrSize + 10, qrSize + 10);
ctx.fillStyle = '#fff';
ctx.fillRect(qrX, qrY, qrSize, qrSize);
try {
await drawImage(ctx, qrSrc, qrX, qrY, qrSize, qrSize);
} catch (e) {
if (qrModules.value.length) {
drawQrModulesToCanvas(ctx, qrModules.value, qrX, qrY, qrSize);
}
}
// 7. 邀请码
ctx.textAlign = 'center';
ctx.fillStyle = '#8b2323';
ctx.font = '12px sans-serif';
ctx.fillText(`邀请码:${props.userId}`, w / 2, qrY + qrSize + 20);
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.fillText('长按识别 · 开启好运', w / 2, qrY + qrSize + 38);
// 8. 底部
ctx.fillStyle = '#d4af37';
ctx.font = '11px sans-serif';
ctx.fillText('— 邀您共探姓名玄机 —', w / 2, h - 20);
ctx.fillRect(0, h - 5, w, 5);
return posterCanvas.toDataURL('image/png');
};
const preparePoster = async () => {
if (isSaving.value) return;
isSaving.value = true;
showLoading('生成中...');
try {
const qrSrc = qrImageUrl.value || await generateQrImage();
posterDataUrl.value = await buildPosterDataUrl(qrSrc);
} catch (err: any) {
console.error('海报生成失败:', err);
showToast({ title: '生成失败', icon: 'none' });
} finally {
hideLoading();
isSaving.value = false;
}
};
const handleSave = async () => {
if (isSaving.value) return;
if (!posterDataUrl.value) {
await preparePoster();
}
if (!posterDataUrl.value) return;
const downloaded = triggerDownload(posterDataUrl.value, `poster-${props.userId || 'share'}.png`);
showToast({ title: downloaded ? '已下载海报' : '请长按图片保存', icon: downloaded ? 'success' : 'none' });
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-box {
width: 85%;
max-width: 600rpx;
position: relative;
}
.close-btn {
position: absolute;
top: -50rpx;
right: 0;
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.7);
}
/* 海报预览 */
.poster-preview {
background: linear-gradient(180deg, #1a0a0a 0%, #2d1515 50%, #1a0a0a 100%);
border-radius: 16rpx;
padding: 40rpx 32rpx;
border: 4rpx solid #d4af37;
}
.poster-header {
text-align: center;
margin-bottom: 32rpx;
}
.poster-symbol {
font-size: 44rpx;
color: #d4af37;
display: block;
margin-bottom: 12rpx;
}
.poster-title {
font-size: 44rpx;
font-weight: bold;
color: #f2e6d8;
letter-spacing: 0.2em;
display: block;
}
.poster-subtitle {
font-size: 20rpx;
color: #d4af37;
display: block;
margin-top: 12rpx;
}
.poster-card {
background: #fffdf9;
border-radius: 12rpx;
padding: 28rpx 24rpx;
}
.service-list {
display: flex;
flex-wrap: wrap;
}
.service-item {
width: 50%;
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.service-dot {
width: 10rpx;
height: 10rpx;
background: #8b2323;
border-radius: 50%;
margin-right: 12rpx;
}
.service-text {
font-size: 24rpx;
color: #333;
}
.poster-divider {
height: 1rpx;
background: #d4af37;
margin: 20rpx 0;
}
.qr-area {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-box {
width: 180rpx;
height: 180rpx;
padding: 8rpx;
border: 2rpx solid #d4af37;
background: #fff;
}
.qr-image {
width: 100%;
height: 100%;
}
.invite-text {
font-size: 22rpx;
color: #8b2323;
margin-top: 16rpx;
}
.scan-text {
font-size: 18rpx;
color: #999;
margin-top: 6rpx;
}
.poster-footer {
text-align: center;
font-size: 20rpx;
color: #d4af37;
margin-top: 24rpx;
display: block;
}
/* 按钮 */
.btn-row {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
}
.btn {
flex: 1;
height: 84rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
border: none;
padding: 0;
margin: 0;
}
.btn::after {
border: none;
}
.btn-share {
background: linear-gradient(135deg, #8b2323, #6b1a1a);
}
.btn-save {
background: linear-gradient(135deg, #d4af37, #b8962e);
}
.btn-label {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
.poster-save-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10000;
}
.poster-save-tip {
color: #fff;
font-size: 28rpx;
margin-bottom: 24rpx;
}
.poster-save-image {
max-width: 80%;
max-height: 70vh;
border-radius: 16rpx;
}
.poster-save-loading {
color: rgba(255, 255, 255, 0.8);
font-size: 24rpx;
padding: 60rpx 0;
}
.poster-save-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.poster-save-btn {
background: linear-gradient(135deg, #d4af37, #b8962e);
color: #fff;
font-size: 26rpx;
border-radius: 12rpx;
padding: 14rpx 40rpx;
}
.poster-save-btn-disabled {
opacity: 0.6;
}
.poster-save-close {
margin-top: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
padding: 16rpx 40rpx;
}
</style>