upload project source code
This commit is contained in:
597
前端源码/uni-app/components/SharePosterModal.vue
Normal file
597
前端源码/uni-app/components/SharePosterModal.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user