upload project source code

This commit is contained in:
2026-04-30 18:49:43 +08:00
commit 9b394ba682
2277 changed files with 660945 additions and 0 deletions

View 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>