Files
spring-festival-greetings/components/LuckyPopup/LuckyPopup.vue
2026-02-24 20:08:08 +08:00

862 lines
22 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>
<view class="lucky-popup-container">
<uni-popup ref="popup" type="center" :mask-click="false">
<!-- 动画阶段 -->
<view v-if="isAnimating" class="animation-container">
<view class="card-flip-wrapper" :class="{ flipped: isFlipping }">
<view class="card-front">
<view class="loading-circle">
<view class="particle p1"></view>
<view class="particle p2"></view>
<view class="particle p3"></view>
<view class="particle p4"></view>
</view>
<text class="loading-text">{{ loadingText }}</text>
</view>
<view class="card-back"></view>
</view>
<view class="light-effect" v-if="showLight"></view>
</view>
<!-- 结果阶段 -->
<view v-else class="result-container">
<view class="lucky-card" id="lucky-card">
<!-- 头部渐变区 -->
<view class="card-header">
<view class="header-decor left"></view>
<view class="header-decor right"></view>
<text class="header-label">今日好运指数</text>
<view class="score-wrap">
<text class="score">{{ resultData.score }}</text>
<text class="percent">%</text>
</view>
<text class="lucky-word">{{ resultData.luckyWord }}</text>
<view class="tag-year">{{ currentDateStr }}</view>
</view>
<!-- 内容区 -->
<view class="card-body">
<!-- 宜忌 -->
<view class="yi-ji-grid">
<view class="grid-item yi">
<view class="item-title">
<uni-icons type="checkmarkempty" size="16" color="#d81e06" />
<text>今日宜</text>
</view>
<text class="item-content">{{ resultData.yi }}</text>
</view>
<view class="grid-item ji">
<view class="item-title">
<uni-icons type="closeempty" size="16" color="#666" />
<text>今日忌</text>
</view>
<text class="item-content">{{ resultData.ji }}</text>
</view>
</view>
<!-- 幸运元素 -->
<view class="lucky-elements">
<view class="elements-title">
<uni-icons type="star-filled" size="16" color="#ffca28" />
<text>幸运元素</text>
</view>
<view class="elements-row">
<view class="el-item">
<text class="label">颜色</text>
<text class="value color-val">{{
resultData.luckyColor
}}</text>
</view>
<view class="divider"></view>
<view class="el-item">
<text class="label">数字</text>
<text class="value">{{ resultData.luckyNumber }}</text>
</view>
<view class="divider"></view>
<view class="el-item">
<text class="label">方向</text>
<text class="value">{{ resultData.luckyDirection }}</text>
</view>
</view>
</view>
<view class="quote-text">{{ resultData.quote }}</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-actions">
<view class="action-btn" @tap="onSaveImage">
<uni-icons type="download" size="24" color="#fff" />
<text class="btn-label">保存</text>
</view>
<button class="action-btn share-btn" open-type="share">
<uni-icons type="paperplane" size="24" color="#fff" />
<text class="btn-label">好友</text>
</button>
<view class="action-btn" @tap="onShareMoments">
<uni-icons type="camera" size="24" color="#fff" />
<text class="btn-label">朋友圈</text>
</view>
<view class="action-btn close-btn" @tap="close">
<uni-icons type="closeempty" size="24" color="#fff" />
<text class="btn-label">关闭</text>
</view>
</view>
</view>
</uni-popup>
<!-- 画布用于生成图片 -->
<canvas
canvas-id="luckyCanvas"
id="luckyCanvas"
class="lucky-canvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
></canvas>
</view>
</template>
<script setup>
import { ref, getCurrentInstance } from "vue";
import calendar from "@/utils/lunar.js";
const { proxy } = getCurrentInstance();
const popup = ref(null);
const isAnimating = ref(true);
const isFlipping = ref(false);
const showLight = ref(false);
const loadingText = ref("好运加载中...");
const currentDateStr = ref("");
// 画布相关
const canvasWidth = ref(600);
const canvasHeight = ref(1000);
const resultData = ref({
score: 88,
luckyWord: "鸿运当头",
yi: "沟通合作、尝试新事物",
ji: "熬夜、冲动消费",
luckyColor: "如意金",
luckyNumber: "6",
luckyDirection: "东南",
quote: "今天适合向前一步,好运正在回应你的努力。",
});
const texts = ["好运加载中...", "今日能量汇集中 ✨", "正在计算你的幸运指数..."];
const open = () => {
isAnimating.value = true;
isFlipping.value = false;
showLight.value = false;
loadingText.value = texts[0];
const now = new Date();
const y = now.getFullYear();
const m = (now.getMonth() + 1).toString().padStart(2, "0");
const d = now.getDate().toString().padStart(2, "0");
const lunar = calendar.solar2lunar(now);
currentDateStr.value = `${y}.${m}.${d} ${lunar.lunarDateStr}`;
popup.value.open();
startAnimation();
};
const close = () => {
popup.value.close();
};
const startAnimation = () => {
// 文字轮播
let step = 0;
const timer = setInterval(() => {
step++;
if (step < texts.length) {
loadingText.value = texts[step];
}
}, 600);
// 1.5s 后翻转
setTimeout(() => {
clearInterval(timer);
isFlipping.value = true;
showLight.value = true;
// 动画结束后显示结果
setTimeout(() => {
isAnimating.value = false;
}, 600);
}, 1800);
};
const onSaveImage = () => {
uni.showLoading({ title: "生成图片中..." });
const ctx = uni.createCanvasContext("luckyCanvas", proxy);
const W = canvasWidth.value;
const H = canvasHeight.value;
const cardH = 850; // 卡片主体高度
// 1. 绘制背景
ctx.setFillStyle("#ffffff");
ctx.fillRect(0, 0, W, H);
// 2. 绘制卡片头部渐变
const grd = ctx.createLinearGradient(0, 0, 0, 360);
grd.addColorStop(0, "#d84315");
grd.addColorStop(1, "#ffca28");
ctx.setFillStyle(grd);
ctx.fillRect(0, 0, W, 360);
// 3. 头部装饰文字
ctx.setFillStyle("rgba(255, 255, 255, 0.6)");
ctx.setFontSize(24);
ctx.fillText("福", 30, 40);
ctx.fillText("禧", W - 50, 40);
// 4. 头部内容
ctx.setTextAlign("center");
ctx.setFillStyle("rgba(255, 255, 255, 0.9)");
ctx.setFontSize(24);
ctx.fillText("今日好运指数", W / 2, 80);
// 分数
ctx.setFillStyle("#ffffff");
ctx.setFontSize(120);
ctx.font = "bold 120px sans-serif";
ctx.fillText(resultData.value.score + "%", W / 2, 200);
// 幸运词
ctx.setFontSize(48);
ctx.font = "bold 48px sans-serif";
ctx.fillText(resultData.value.luckyWord, W / 2, 280);
// 日期标签背景
const dateStr = currentDateStr.value || "2026 CNY SPECIAL";
ctx.setFillStyle("rgba(0, 0, 0, 0.15)");
const dateWidth = ctx.measureText(dateStr).width + 40;
roundRect(ctx, W / 2 - dateWidth / 2, 310, dateWidth, 34, 17);
ctx.fill();
// 日期文字
ctx.setFillStyle("#ffffff");
ctx.setFontSize(20);
ctx.fillText(dateStr, W / 2, 334);
// 5. 绘制内容区 (宜/忌)
const gridY = 400;
const boxW = (W - 64 - 24) / 2; // (600 - padding*2 - gap)/2
const gridH = 140; // 增加高度防止内容溢出
// 宜
drawBox(ctx, 32, gridY, boxW, gridH, "#fbfbfb", "#f5f5f5");
ctx.setTextAlign("left");
ctx.setFontSize(24);
ctx.setFillStyle("#d81e06");
ctx.font = "bold 24px sans-serif";
// 图标模拟
ctx.fillText("✔ 今日宜", 56, gridY + 44);
ctx.setFontSize(22);
ctx.setFillStyle("#666666");
ctx.font = "normal 22px sans-serif";
wrapText(ctx, resultData.value.yi, 56, gridY + 80, boxW - 48, 30);
// 忌
drawBox(ctx, 32 + boxW + 24, gridY, boxW, gridH, "#fbfbfb", "#f5f5f5");
ctx.setFontSize(24);
ctx.setFillStyle("#666666");
ctx.font = "bold 24px sans-serif";
ctx.fillText("✖ 今日忌", 32 + boxW + 24 + 24, gridY + 44);
ctx.setFontSize(22);
ctx.font = "normal 22px sans-serif";
wrapText(
ctx,
resultData.value.ji,
32 + boxW + 24 + 24,
gridY + 80,
boxW - 48,
30,
);
// 6. 幸运元素
const elY = 570; // 下移,避免与上面重叠
const elH = 160; // 增加高度
drawBox(ctx, 32, elY, W - 64, elH, "#fbfbfb", "#f5f5f5");
// 标题
ctx.setFontSize(26);
ctx.setFillStyle("#333333");
ctx.font = "bold 26px sans-serif";
ctx.fillText("★ 幸运元素", 56, elY + 46);
// 元素内容
const contentW = W - 64; // 内容区域总宽度
const colW = contentW / 3; // 三等分
const startX = 32; // 起始X坐标
// 调整Y坐标确保不重叠
const labelY = elY + 90;
const valY = elY + 126;
// 颜色 (第一列)
ctx.setTextAlign("center");
ctx.setFontSize(20);
ctx.setFillStyle("#999999");
ctx.font = "normal 20px sans-serif";
ctx.fillText("颜色", startX + colW * 0.5, labelY);
ctx.setFontSize(26);
ctx.setFillStyle("#d84315");
ctx.font = "bold 26px sans-serif";
ctx.fillText(resultData.value.luckyColor, startX + colW * 0.5, valY);
// 数字 (第二列)
ctx.setFontSize(20);
ctx.setFillStyle("#999999");
ctx.font = "normal 20px sans-serif";
ctx.fillText("数字", startX + colW * 1.5, labelY);
ctx.setFontSize(26);
ctx.setFillStyle("#333333");
ctx.font = "bold 26px sans-serif";
ctx.fillText(resultData.value.luckyNumber, startX + colW * 1.5, valY);
// 方向 (第三列)
ctx.setFontSize(20);
ctx.setFillStyle("#999999");
ctx.font = "normal 20px sans-serif";
ctx.fillText("方向", startX + colW * 2.5, labelY);
ctx.setFontSize(26);
ctx.setFillStyle("#333333");
ctx.font = "bold 26px sans-serif";
ctx.fillText(resultData.value.luckyDirection, startX + colW * 2.5, valY);
// 分隔线 1
ctx.setStrokeStyle("#eeeeee");
ctx.setLineWidth(2);
ctx.beginPath();
const lineTop = elY + 70;
const lineBottom = elY + 130;
ctx.moveTo(startX + colW, lineTop);
ctx.lineTo(startX + colW, lineBottom);
ctx.stroke();
// 分隔线 2
ctx.beginPath();
ctx.moveTo(startX + colW * 2, lineTop);
ctx.lineTo(startX + colW * 2, lineBottom);
ctx.stroke();
// 7. 语录
ctx.setTextAlign("center");
ctx.setFontSize(22);
ctx.setFillStyle("#999999");
ctx.font = "italic 22px sans-serif";
// 语录换行处理,下移坐标
wrapTextCentered(ctx, `${resultData.value.quote}`, W / 2, 780, W - 80, 30);
// 8. 底部区域 (Footer)
const footerY = 850;
// 分隔线
ctx.setStrokeStyle("#f0f0f0");
ctx.setLineWidth(1);
ctx.beginPath();
ctx.moveTo(40, footerY);
ctx.lineTo(W - 40, footerY);
ctx.stroke();
// 底部左侧文字
ctx.setTextAlign("left");
ctx.setFontSize(32);
ctx.setFillStyle("#333333");
ctx.font = "bold 32px sans-serif";
ctx.fillText("扫码开启今日好运", 40, footerY + 60);
ctx.setFontSize(20);
ctx.setFillStyle("#999999");
ctx.font = "normal 20px sans-serif";
ctx.fillText("2026 CNY SPECIAL · 新春助手", 40, footerY + 100);
// 底部右侧二维码 (占位图)
// 假设二维码在 static/icon/yunshi.png 或 logo.png
// 实际开发中应替换为小程序码
ctx.drawImage("/static/logo.png", W - 140, footerY + 25, 100, 100);
// 绘制
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: "luckyCanvas",
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.hideLoading();
uni.showToast({ title: "已保存到相册", icon: "success" });
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: "保存失败,请授权", icon: "none" });
},
});
},
fail: (err) => {
uni.hideLoading();
uni.showToast({ title: "生成图片失败", icon: "none" });
console.error(err);
},
},
proxy,
);
}, 200);
});
};
// 辅助函数:绘制圆角矩形
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arc(x + w - r, y + r, r, 1.5 * Math.PI, 2 * Math.PI);
ctx.lineTo(x + w, y + h - r);
ctx.arc(x + w - r, y + h - r, r, 0, 0.5 * Math.PI);
ctx.lineTo(x + r, y + h);
ctx.arc(x + r, y + h - r, r, 0.5 * Math.PI, Math.PI);
ctx.lineTo(x, y + r);
ctx.arc(x + r, y + r, r, Math.PI, 1.5 * Math.PI);
ctx.closePath();
}
// 辅助函数:绘制带背景边框的盒子
function drawBox(ctx, x, y, w, h, bg, border) {
ctx.setFillStyle(bg);
ctx.setStrokeStyle(border);
ctx.setLineWidth(2);
roundRect(ctx, x, y, w, h, 20);
ctx.fill();
ctx.stroke();
}
// 辅助函数:文字换行
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
let words = text.split("");
let line = "";
for (let n = 0; n < words.length; n++) {
let testLine = line + words[n];
let metrics = ctx.measureText(testLine);
let testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
ctx.fillText(line, x, y);
line = words[n];
y += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, y);
}
// 辅助函数:文字换行(居中)
function wrapTextCentered(ctx, text, x, y, maxWidth, lineHeight) {
let words = text.split("");
let line = "";
for (let n = 0; n < words.length; n++) {
let testLine = line + words[n];
let metrics = ctx.measureText(testLine);
let testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
ctx.fillText(line, x, y);
line = words[n];
y += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, y);
}
const onShareMoments = () => {
uni.showToast({ title: "请点击右上角分享", icon: "none" });
};
defineExpose({ open, close });
</script>
<style lang="scss" scoped>
.lucky-popup-container {
/* 动画容器 */
.animation-container {
width: 600rpx;
height: 850rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
perspective: 1000px;
}
.card-flip-wrapper {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
&.flipped {
transform: rotateY(180deg);
}
}
.card-front,
.card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 40rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-shadow: 0 0 40rpx rgba(255, 215, 0, 0.3);
}
.card-front {
background: linear-gradient(135deg, #fffcf5 0%, #fff4e6 100%);
z-index: 2;
.loading-circle {
width: 120rpx;
height: 120rpx;
position: relative;
margin-bottom: 40rpx;
.particle {
position: absolute;
width: 20rpx;
height: 20rpx;
background: #ff8f00;
border-radius: 50%;
animation: orbit 1.5s linear infinite;
/* 绝对居中 */
top: 50%;
left: 50%;
margin-top: -10rpx;
margin-left: -10rpx;
&.p1 {
animation-delay: 0s;
}
&.p2 {
animation-delay: -0.375s;
}
&.p3 {
animation-delay: -0.75s;
}
&.p4 {
animation-delay: -1.125s;
}
}
}
.loading-text {
color: #d81e06;
font-size: 28rpx;
letter-spacing: 2rpx;
font-weight: 500;
}
}
.card-back {
background: #fff;
transform: rotateY(180deg);
}
.light-effect {
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(
circle,
rgba(255, 215, 0, 0.8) 0%,
transparent 70%
);
opacity: 0;
animation: flash 0.6s ease-out forwards;
pointer-events: none;
}
/* 结果展示 */
.result-container {
width: 600rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.lucky-card {
width: 100%;
height: 850rpx;
background: #fff;
border-radius: 40rpx;
overflow: hidden;
margin-bottom: 40rpx;
.card-header {
height: 360rpx;
background: linear-gradient(180deg, #d84315 0%, #ffca28 100%);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
.header-decor {
position: absolute;
top: 20rpx;
font-size: 24rpx;
opacity: 0.6;
&.left {
left: 30rpx;
}
&.right {
right: 30rpx;
}
}
.header-label {
font-size: 24rpx;
margin-bottom: 16rpx;
opacity: 0.9;
letter-spacing: 2rpx;
}
.score-wrap {
display: flex;
align-items: baseline;
line-height: 1;
margin-bottom: 16rpx;
.score {
font-size: 120rpx;
font-weight: bold;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.percent {
font-size: 40rpx;
margin-left: 4rpx;
font-weight: 500;
}
}
.lucky-word {
font-size: 48rpx;
font-weight: bold;
letter-spacing: 4rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
margin-bottom: 32rpx;
}
.tag-year {
background: rgba(0, 0, 0, 0.15);
padding: 8rpx 24rpx;
border-radius: 30rpx;
font-size: 20rpx;
letter-spacing: 2rpx;
}
}
.card-body {
padding: 40rpx 32rpx;
.yi-ji-grid {
display: flex;
gap: 24rpx;
margin-bottom: 32rpx;
.grid-item {
flex: 1;
background: #fbfbfb;
border-radius: 20rpx;
padding: 24rpx;
border: 2rpx solid #f5f5f5;
.item-title {
display: flex;
align-items: center;
margin-bottom: 12rpx;
text {
font-size: 24rpx;
font-weight: bold;
color: #333;
margin-left: 8rpx;
}
}
.item-content {
font-size: 22rpx;
color: #666;
line-height: 1.4;
}
}
}
.lucky-elements {
background: #fbfbfb;
border-radius: 20rpx;
padding: 24rpx;
border: 2rpx solid #f5f5f5;
margin-bottom: 32rpx;
.elements-title {
display: flex;
align-items: center;
margin-bottom: 20rpx;
text {
font-size: 26rpx;
font-weight: bold;
color: #333;
margin-left: 8rpx;
}
}
.elements-row {
display: flex;
justify-content: space-between;
align-items: center;
.el-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.label {
font-size: 20rpx;
color: #999;
margin-bottom: 8rpx;
}
.value {
font-size: 26rpx;
color: #333;
font-weight: 600;
&.color-val {
color: #d84315;
}
}
}
.divider {
width: 2rpx;
height: 40rpx;
background: #eee;
}
}
}
.quote-text {
text-align: center;
font-size: 22rpx;
color: #999;
font-style: italic;
}
}
}
.bottom-actions {
width: 100%;
display: flex;
justify-content: space-around;
padding: 0 40rpx;
box-sizing: border-box;
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
background: none;
border: none;
padding: 0;
margin: 0;
line-height: 1.2;
&::after {
border: none;
}
:deep(.uni-icons) {
width: 88rpx;
height: 88rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(10rpx);
border: 2rpx solid rgba(255, 255, 255, 0.2);
margin-bottom: 12rpx;
transition: all 0.3s;
}
&:active :deep(.uni-icons) {
background: rgba(255, 255, 255, 0.25);
transform: scale(0.95);
}
.btn-label {
font-size: 24rpx;
color: #fff;
opacity: 0.9;
}
}
}
}
@keyframes orbit {
0% {
transform: rotate(0deg) translateX(40rpx) rotate(0deg);
}
100% {
transform: rotate(360deg) translateX(40rpx) rotate(-360deg);
}
}
@keyframes flash {
0% {
opacity: 0;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 0;
transform: scale(1.5);
}
}
.lucky-canvas {
position: fixed;
left: -9999px;
top: 0;
}
</style>