598 lines
15 KiB
Vue
598 lines
15 KiB
Vue
<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>
|