接龙调整

This commit is contained in:
hebo 2025-12-02 20:25:48 +08:00
parent 534831f592
commit 33a4cac829
5 changed files with 1392 additions and 0 deletions

26
src/api/base/srApi.ts Normal file
View File

@ -0,0 +1,26 @@
// 生日贺卡相关API接口
import { get, post } from "@/utils/request";
/**
*
* @param params { id: 推送记录ID }
*/
export const srGetCardDetailApi = async (params: { id: string }) => {
return await get("/api/srTsRecord/getCardDetail", params);
};
/**
*
* @param params { xxtsId: 消息推送ID }
*/
export const srMarkAsViewedApi = async (params: { xxtsId: string }) => {
return await post("/api/srTsRecord/markAsViewed", params);
};
/**
*
*/
export const srFindPageApi = async (params: any) => {
return await get("/api/srTsRecord/findPage", params);
};

View File

@ -381,6 +381,24 @@
"navigationBarTitleText": "收款码",
"enablePullDownRefresh": false
}
},
{
"path": "pages/base/birthday/envelope",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "生日祝福",
"enablePullDownRefresh": false,
"backgroundColor": "#ff9a9e"
}
},
{
"path": "pages/base/birthday/card",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "生日祝福",
"enablePullDownRefresh": false,
"backgroundColor": "#ff9a9e"
}
}
],
"globalStyle": {

View File

@ -0,0 +1,695 @@
<!-- src/pages/base/birthday/card.vue -->
<!-- 生日贺卡展示页面家长端 - 信纸样式 -->
<template>
<view class="birthday-card-page" :class="bgColorClass">
<!-- 加载遮罩层 -->
<view v-if="isLoading" class="loading-overlay">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 贺卡内容 -->
<view v-else-if="cardData" class="card-container">
<!-- 信纸主体 -->
<view class="letter-paper">
<!-- 信纸顶部装饰 -->
<view class="paper-header">
<view class="header-line"></view>
<view class="header-title">
<text class="title-icon">🎂</text>
<text class="title-text">生日祝福</text>
<text class="title-icon">🎂</text>
</view>
<view class="header-date">{{ formatBirthdayDate }}</view>
</view>
<!-- 称呼 -->
<view class="letter-greeting" v-if="greetingFromContent">
<view class="greeting-line">
<text class="greeting-text">{{ greetingFromContent }}</text>
</view>
</view>
<!-- 信件正文带虚线 -->
<view class="letter-content">
<view
v-for="(line, index) in contentLines"
:key="index"
class="content-line"
>
<text class="line-text">{{ line || '\u00A0' }}</text>
<view class="line-border"></view>
</view>
</view>
<!-- 签名区域 -->
<view class="letter-signature" v-if="cardData.signerName || cardData.signLabel">
<view class="signature-wrapper">
<!-- 签名文字 -->
<view class="signature-info">
<text class="signer-name">
<text v-if="cardData.signLabel" class="signer-label">{{ cardData.signLabel }}</text>
<text v-if="cardData.signerName">{{ cardData.signerName }}</text>
</text>
</view>
<!-- 日期 -->
<view class="signature-date">
<text>{{ currentDate }}</text>
</view>
</view>
</view>
<!-- 信纸底部装饰 -->
<view class="paper-footer">
<view class="footer-decoration">
<text class="footer-icon">🎉</text>
<text class="footer-icon">🎁</text>
<text class="footer-icon">🎈</text>
</view>
</view>
</view>
<!-- 右上角音乐图标 -->
<view
class="music-icon-wrapper"
:class="{ 'playing': isMusicPlaying }"
@click="toggleMusic"
>
<text class="music-icon-emoji">🎵</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-icon">🎂</text>
<text class="empty-text">贺卡不存在或已过期</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onUnmounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { srGetCardDetailApi, srMarkAsViewedApi } from "@/api/base/srApi";
import { imagUrl } from "@/utils";
const xxtsId = ref<string>("");
const cardData = ref<any>(null);
const isLoading = ref(false);
const isMusicPlaying = ref(false);
const audioContext = ref<any>(null);
//
const formatBirthdayDate = computed(() => {
if (!cardData.value?.birthdayDate) return '';
const date = cardData.value.birthdayDate;
if (date.includes('月')) return date;
try {
const d = new Date(date);
return `${d.getMonth() + 1}${d.getDate()}`;
} catch {
return date;
}
});
//
const currentDate = computed(() => {
const now = new Date();
return `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
});
// 寿
const personTypeText = computed(() => {
const type = cardData.value?.srPersonType;
if (type === 'JS') return '老师';
if (type === 'XS') return '同学';
return '';
});
//
const bgColorClass = computed(() => {
const colorType = cardData.value?.hkMb?.animationType || 'blue';
return `bg-${colorType}`;
});
// HTML
const stripHtmlTags = (html: string): string => {
if (!html) return '';
// 使HTML
return html.replace(/<[^>]+>/g, '').trim();
};
//
const greetingFromContent = computed(() => {
if (!cardData.value?.zfContent) return '';
const content = cardData.value.zfContent;
// </p>
const lines = content.split(/<\/p>|<\/div>|\n/).filter((line: string) => line.trim());
if (lines.length > 0) {
// HTML
let firstLine = lines[0];
// <p><div>
firstLine = firstLine.replace(/^<[^>]+>/, '');
// HTML
firstLine = stripHtmlTags(firstLine).trim();
return firstLine || '';
}
return '';
});
// HTML线
const contentLines = computed(() => {
if (!cardData.value?.zfContent) return [''];
const content = cardData.value.zfContent;
// </p>
let lines = content.split(/<\/p>|<\/div>|\n/).filter((line: string) => line.trim());
//
if (lines.length > 0) {
lines = lines.slice(1);
}
// HTML
lines = lines.map((line: string) => {
// <p><div>
line = line.replace(/^<[^>]+>/, '');
// HTML
return stripHtmlTags(line).trim();
}).filter((line: string) => line); //
// 5
const minLines = 5;
while (lines.length < minLines) {
lines.push('');
}
return lines;
});
//
const loadCardDetail = async () => {
if (!xxtsId.value) return;
isLoading.value = true;
try {
const res = await srGetCardDetailApi({ id: xxtsId.value });
if (res?.result) {
cardData.value = res.result;
const returnedXxtsId = res.result.xxtsId || xxtsId.value;
if (returnedXxtsId) {
await markAsViewed(returnedXxtsId);
}
// 使使
initAudio();
}
} catch (e) {
console.error("加载贺卡详情失败:", e);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
isLoading.value = false;
}
};
//
const markAsViewed = async (id: string) => {
if (!id) return;
try {
await srMarkAsViewedApi({ xxtsId: id });
} catch (e) {
console.error("标记已查阅失败:", e);
}
};
//
const initAudio = () => {
// 使使
const musicUrl = cardData.value?.hkMb?.musicUrl
? imagUrl(cardData.value.hkMb.musicUrl)
: '/static/base/music/srkl.mp3';
try {
// #ifdef H5
audioContext.value = new Audio(musicUrl);
audioContext.value.loop = true;
audioContext.value.play().catch((e: any) => {
console.error("自动播放失败,可能需要用户交互:", e);
});
// #endif
// #ifdef MP-WEIXIN
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = musicUrl;
audioContext.value.loop = true;
audioContext.value.play();
// #endif
//
isMusicPlaying.value = true;
} catch (e) {
console.error("初始化音频失败:", e);
}
};
//
const toggleMusic = () => {
if (!audioContext.value) return;
try {
if (isMusicPlaying.value) {
audioContext.value.pause();
} else {
audioContext.value.play();
}
isMusicPlaying.value = !isMusicPlaying.value;
} catch (e) {
console.error("音乐播放控制失败:", e);
}
};
//
onUnmounted(() => {
if (audioContext.value) {
try {
audioContext.value.pause();
// #ifdef MP-WEIXIN
audioContext.value.destroy?.();
// #endif
} catch (e) {
console.error("清理音频失败:", e);
}
}
});
onLoad(async (options) => {
xxtsId.value = options?.id || '';
if (xxtsId.value) {
await loadCardDetail();
uni.setNavigationBarTitle({ title: "生日祝福" });
} else {
uni.showToast({ title: "缺少贺卡ID", icon: "none" });
}
});
</script>
<style scoped lang="scss">
.birthday-card-page {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
//
&.bg-blue {
background: linear-gradient(180deg, #e6f3ff 0%, #cce5ff 50%, #b3d9ff 100%);
.letter-paper {
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
border-color: rgba(59, 130, 246, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #3b82f6 50%, transparent 100%);
}
.title-text {
color: #1e40af;
}
.paper-footer {
border-top-color: #93c5fd;
}
.loading-overlay {
background-color: rgba(230, 243, 255, 0.95);
}
.loading-spinner {
border-color: #93c5fd;
border-top-color: #3b82f6;
}
}
//
&.bg-yellow {
background: linear-gradient(180deg, #fdf6e3 0%, #f5e6c8 50%, #efe0b9 100%);
.letter-paper {
background: linear-gradient(180deg, #fffef5 0%, #fff9e6 100%);
border-color: rgba(201, 162, 39, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #c9a227 50%, transparent 100%);
}
.title-text {
color: #8b4513;
}
.paper-footer {
border-top-color: #e8d5a3;
}
.loading-overlay {
background-color: rgba(253, 246, 227, 0.95);
}
.loading-spinner {
border-color: #e8d5a3;
border-top-color: #c9a227;
}
}
//
&.bg-purple {
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 50%, #ddd6fe 100%);
.letter-paper {
background: linear-gradient(180deg, #faf5ff 0%, #f5f0ff 100%);
border-color: rgba(139, 92, 246, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #8b5cf6 50%, transparent 100%);
}
.title-text {
color: #6b21a8;
}
.paper-footer {
border-top-color: #c4b5fd;
}
.loading-overlay {
background-color: rgba(243, 232, 255, 0.95);
}
.loading-spinner {
border-color: #c4b5fd;
border-top-color: #8b5cf6;
}
}
}
/* 加载遮罩 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
width: 50px;
height: 50px;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 16px;
color: #666;
}
/* 贺卡容器 */
.card-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
/* 信纸主体 */
.letter-paper {
flex: 1;
border-radius: 8px;
padding: 30px 25px;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
border: 1px solid;
position: relative;
display: flex;
flex-direction: column;
}
/* 信纸左边红色竖线(模拟信纸) */
.letter-paper::before {
content: '';
position: absolute;
left: 20px;
top: 80px;
bottom: 100px;
width: 2px;
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
opacity: 0.3;
}
/* 信纸顶部装饰 */
.paper-header {
text-align: center;
margin-bottom: 25px;
padding-bottom: 15px;
}
.header-line {
height: 2px;
margin-bottom: 15px;
}
.header-title {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.title-icon {
font-size: 24px;
}
.title-text {
font-size: 26px;
font-weight: bold;
font-family: "KaiTi", "STKaiti", "楷体", serif;
}
.header-date {
font-size: 14px;
color: #a08060;
}
/* 称呼 */
.letter-greeting {
margin-bottom: 20px;
padding-left: 15px;
}
.greeting-line {
padding-bottom: 8px;
border-bottom: 1px dashed #d4c4a8;
}
.greeting-text {
font-size: 18px;
color: #5a4a3a;
font-family: "KaiTi", "STKaiti", "楷体", serif;
}
/* 信件正文 */
.letter-content {
flex: 1;
padding-left: 15px;
margin-bottom: 30px;
}
.content-line {
position: relative;
min-height: 40px;
display: flex;
align-items: flex-end;
padding-bottom: 8px;
}
.line-text {
font-size: 16px;
line-height: 1.8;
color: #4a3a2a;
font-family: "KaiTi", "STKaiti", "楷体", serif;
text-indent: 2em;
display: block;
width: 100%;
}
.line-border {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
border-bottom: 1px dashed #d4c4a8;
}
/* 签名区域 */
.letter-signature {
margin-top: auto;
padding-top: 20px;
}
.signature-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
padding-right: 20px;
}
.signature-image {
width: 100px;
height: 45px;
margin-bottom: 5px;
}
.signature-info {
text-align: right;
margin-bottom: 8px;
}
.signer-label {
font-size: 14px;
color: #8b7355;
display: inline;
margin-right: 8px;
}
.signer-name {
font-size: 18px;
color: #5a4a3a;
font-weight: 500;
font-family: "KaiTi", "STKaiti", "楷体", serif;
display: inline;
}
.signature-date {
font-size: 14px;
color: #a08060;
border-bottom: 1px dashed #d4c4a8;
padding-bottom: 5px;
}
/* 信纸底部装饰 */
.paper-footer {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid;
}
.footer-decoration {
display: flex;
justify-content: center;
gap: 20px;
padding-top: 15px;
}
.footer-icon {
font-size: 24px;
animation: float-icon 3s ease-in-out infinite;
}
.footer-icon:nth-child(2) {
animation-delay: 0.5s;
}
.footer-icon:nth-child(3) {
animation-delay: 1s;
}
@keyframes float-icon {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
/* 右上角音乐图标 */
.music-icon-wrapper {
position: fixed;
top: calc(20px + env(safe-area-inset-top));
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
z-index: 100;
cursor: pointer;
transition: all 0.3s ease;
}
.music-icon-wrapper:active {
transform: scale(0.95);
}
.music-icon-wrapper.playing .music-icon-emoji {
animation: music-rotate 2s linear infinite;
}
.music-icon-emoji {
font-size: 28px;
display: block;
transition: transform 0.3s ease;
}
@keyframes music-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 空状态 */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-text {
font-size: 16px;
color: #8b7355;
margin-bottom: 30px;
}
</style>

View File

@ -0,0 +1,653 @@
<!-- src/pages/base/birthday/envelope.vue -->
<!-- 生日贺卡信封页面家长端 -->
<template>
<view class="envelope-page" :style="pageStyle">
<!-- 装饰背景 -->
<view class="decoration-bg">
<view class="sparkle sparkle-1"></view>
<view class="sparkle sparkle-2"></view>
<view class="sparkle sparkle-3"></view>
<view class="sparkle sparkle-4">🎉</view>
<view class="sparkle sparkle-5"></view>
<view class="sparkle sparkle-6"></view>
</view>
<!-- 提示文字 -->
<view class="hint-text" :class="{ 'fade-out': isOpening }">
<text>您收到一封生日祝福</text>
<text class="hint-sub">点击信封查看</text>
</view>
<!-- 信封容器 -->
<view
class="envelope-wrapper"
:class="{ 'stop-swing': isOpening, 'opened': isOpened }"
@click="openEnvelope"
>
<!-- 信封主体 -->
<view class="envelope">
<!-- 信封背面内衬 -->
<view class="envelope-back"></view>
<!-- 信纸 -->
<view class="letter" :class="{ 'slide-out': isOpening }">
<view class="letter-content">
<text class="letter-emoji">🎂</text>
<text class="letter-title">生日快乐</text>
<text class="letter-hint">点击查看完整贺卡</text>
</view>
</view>
<!-- 信封盖子三角形翻盖 -->
<view class="envelope-flap" :class="{ 'flap-open': isOpening }"></view>
<!-- 信封正面 -->
<view class="envelope-front">
<!-- 封印 -->
<view class="seal">
<text class="seal-icon">🎂</text>
</view>
</view>
</view>
<!-- 手指提示 -->
<view class="finger-hint" :class="{ 'fade-out': isOpening }">
<text class="finger-icon">👆</text>
</view>
</view>
<!-- 底部装饰 -->
<view class="bottom-decoration" :class="{ 'fade-out': isOpening }">
<text class="deco-icon">🎈</text>
<text class="deco-icon">🎁</text>
<text class="deco-icon">🎈</text>
</view>
<!-- 右上角音乐图标 -->
<view
class="music-icon-wrapper"
:class="{ 'playing': isMusicPlaying, 'fade-out': isOpening }"
@click="toggleMusic"
>
<text class="music-icon-emoji">🎵</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { useUserStore } from "@/store/modules/user";
const userStore = useUserStore();
const xxtsId = ref<string>("");
const openId = ref<string>("");
const receiverName = ref<string>("");
const isOpening = ref(false);
const isOpened = ref(false);
const audioContext = ref<any>(null);
const isMusicPlaying = ref(false);
//
const pageStyle = computed(() => {
return {
background: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fad0c4 100%)',
};
});
//
const openEnvelope = () => {
if (isOpening.value || isOpened.value) return;
isOpening.value = true;
//
setTimeout(() => {
isOpened.value = true;
// openId
setTimeout(() => {
uni.navigateTo({
url: `/pages/base/birthday/card?id=${xxtsId.value}`
});
}, 300);
}, 1500);
};
//
const initAudio = () => {
try {
// #ifdef H5
audioContext.value = new Audio('/static/base/music/srkl.mp3');
audioContext.value.loop = true;
//
audioContext.value.addEventListener('play', () => {
isMusicPlaying.value = true;
});
audioContext.value.addEventListener('pause', () => {
isMusicPlaying.value = false;
});
audioContext.value.addEventListener('ended', () => {
isMusicPlaying.value = false;
});
//
let hasTriedPlay = false;
//
const attemptPlay = () => {
if (!audioContext.value || hasTriedPlay || isMusicPlaying.value) {
return;
}
hasTriedPlay = true;
audioContext.value.play().then(() => {
isMusicPlaying.value = true;
}).catch(() => {
// 便
hasTriedPlay = false;
});
};
//
let hasInteracted = false;
const playOnInteraction = () => {
if (!hasInteracted && !isMusicPlaying.value) {
hasInteracted = true;
attemptPlay();
}
};
//
const events = ['click', 'touchstart'];
events.forEach(eventType => {
document.addEventListener(eventType, playOnInteraction, { once: true, passive: true });
});
//
attemptPlay();
// #endif
// #ifdef MP-WEIXIN
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = '/static/base/music/srkl.mp3';
audioContext.value.loop = true;
//
audioContext.value.onPlay(() => {
isMusicPlaying.value = true;
});
audioContext.value.onPause(() => {
isMusicPlaying.value = false;
});
audioContext.value.onStop(() => {
isMusicPlaying.value = false;
});
//
audioContext.value.play();
isMusicPlaying.value = true;
// #endif
} catch (e) {
console.error("初始化音频失败:", e);
}
};
//
const playMusic = () => {
if (!audioContext.value) return;
try {
audioContext.value.play();
isMusicPlaying.value = true;
} catch (e) {
console.error("播放音乐失败:", e);
}
};
//
const toggleMusic = () => {
if (!audioContext.value) return;
try {
if (isMusicPlaying.value) {
audioContext.value.pause();
isMusicPlaying.value = false;
} else {
audioContext.value.play();
isMusicPlaying.value = true;
}
} catch (e) {
console.error("音乐播放控制失败:", e);
}
};
//
onUnmounted(() => {
if (audioContext.value) {
try {
audioContext.value.pause();
// #ifdef MP-WEIXIN
audioContext.value.destroy?.();
// #endif
} catch (e) {
console.error("清理音频失败:", e);
}
}
});
//
const resetEnvelopeState = () => {
isOpening.value = false;
isOpened.value = false;
};
onLoad(async (options) => {
// URL: ?id=xxx&openId=yyy
xxtsId.value = options?.id || '';
openId.value = options?.openId || '';
receiverName.value = options?.name || '';
// openId
if (openId.value) {
try {
await userStore.loginByOpenId(openId.value);
} catch (e) {
console.error("openId认证失败:", e);
}
}
if (!xxtsId.value) {
uni.showToast({ title: "缺少贺卡ID", icon: "none" });
}
//
initAudio();
});
//
onShow(() => {
resetEnvelopeState();
});
</script>
<style scoped lang="scss">
.envelope-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* 装饰背景 */
.decoration-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.sparkle {
position: absolute;
font-size: 28px;
animation: sparkle-float 4s ease-in-out infinite;
}
.sparkle-1 { top: 8%; left: 8%; animation-delay: 0s; }
.sparkle-2 { top: 12%; right: 12%; animation-delay: 0.5s; }
.sparkle-3 { top: 28%; left: 5%; animation-delay: 1s; }
.sparkle-4 { top: 22%; right: 8%; animation-delay: 1.5s; }
.sparkle-5 { bottom: 22%; left: 12%; animation-delay: 2s; }
.sparkle-6 { bottom: 18%; right: 15%; animation-delay: 2.5s; }
@keyframes sparkle-float {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.6;
}
50% {
transform: translateY(-12px) scale(1.15);
opacity: 1;
}
}
/* 提示文字 */
.hint-text {
position: absolute;
top: 12%;
text-align: center;
z-index: 10;
transition: all 0.5s ease;
}
.hint-text text {
display: block;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hint-text text:first-child {
font-size: 24px;
font-weight: bold;
margin-bottom: 12px;
color: #973cfe;
text-shadow: 0 2px 15px rgba(151, 60, 254, 0.5), 0 0 20px rgba(151, 60, 254, 0.3);
}
.hint-sub {
font-size: 14px;
color: #973cfe;
text-shadow: 0 2px 10px rgba(151, 60, 254, 0.4);
animation: hint-pulse 2s ease-in-out infinite;
}
@keyframes hint-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.hint-text.fade-out {
opacity: 0;
transform: translateY(-30px);
}
/* 信封容器 - 摇摆动画 */
.envelope-wrapper {
position: relative;
z-index: 5;
cursor: pointer;
animation: envelope-swing 2.5s ease-in-out infinite;
transform-origin: top center;
}
@keyframes envelope-swing {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
.envelope-wrapper.stop-swing {
animation: none;
transform: rotate(0deg);
}
.envelope-wrapper:active {
transform: scale(0.98);
}
/* 信封主体 */
.envelope {
position: relative;
width: 300px;
height: 200px;
}
/* 信封背面(内衬) - 信封打开时可见的内部 */
.envelope-back {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 200px;
background: linear-gradient(180deg, #d63031 0%, #c0392b 100%);
border-radius: 8px;
z-index: 1;
}
/* 信纸 */
.letter {
position: absolute;
top: 30px;
left: 25px;
right: 25px;
height: 140px;
background: linear-gradient(180deg, #ffffff 0%, #fff8f0 100%);
border-radius: 6px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15);
z-index: 2;
transform: translateY(0);
transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.letter.slide-out {
transform: translateY(-160px);
}
.letter-content {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.letter-emoji {
font-size: 36px;
margin-bottom: 8px;
}
.letter-title {
font-size: 22px;
font-weight: bold;
color: #e74c3c;
margin-bottom: 8px;
}
.letter-hint {
font-size: 12px;
color: #999;
}
/* 信封盖子 - 三角形翻盖 */
.envelope-flap {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
border-left: 150px solid transparent;
border-right: 150px solid transparent;
border-top: 100px solid #e74c3c;
z-index: 4;
transform-origin: top center;
transform: rotateX(0deg);
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.15));
}
.envelope-flap.flap-open {
transform: rotateX(180deg);
}
/* 信封正面 */
.envelope-front {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 130px;
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
border-radius: 0 0 8px 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
/* 信封左右折角装饰 */
.envelope-front::before,
.envelope-front::after {
content: '';
position: absolute;
top: 0;
width: 0;
height: 0;
}
.envelope-front::before {
left: 0;
border-left: 150px solid #c0392b;
border-top: 65px solid transparent;
border-bottom: 65px solid transparent;
opacity: 0.3;
}
.envelope-front::after {
right: 0;
border-right: 150px solid #c0392b;
border-top: 65px solid transparent;
border-bottom: 65px solid transparent;
opacity: 0.3;
}
/* 封印 */
.seal {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(145deg, #f39c12 0%, #e67e22 50%, #d35400 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4px 15px rgba(0, 0, 0, 0.3),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 5;
animation: seal-glow 2s ease-in-out infinite;
}
@keyframes seal-glow {
0%, 100% {
box-shadow:
0 4px 15px rgba(243, 156, 18, 0.4),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
}
50% {
box-shadow:
0 4px 30px rgba(243, 156, 18, 0.8),
0 0 20px rgba(243, 156, 18, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
}
}
.seal-icon {
font-size: 32px;
}
/* 手指提示 */
.finger-hint {
position: absolute;
bottom: -60px;
left: 50%;
transform: translateX(-50%);
animation: finger-bounce 1s ease-in-out infinite;
transition: opacity 0.3s ease;
}
@keyframes finger-bounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-15px); }
}
.finger-icon {
font-size: 40px;
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2));
}
.finger-hint.fade-out {
opacity: 0;
}
/* 打开后的状态 */
.envelope-wrapper.opened {
animation: envelope-fadeUp 0.5s ease forwards;
}
@keyframes envelope-fadeUp {
to {
opacity: 0;
transform: translateY(-60px) scale(0.9);
}
}
/* 底部装饰 */
.bottom-decoration {
position: absolute;
bottom: 12%;
display: flex;
gap: 25px;
z-index: 10;
transition: all 0.5s ease;
}
.deco-icon {
font-size: 36px;
animation: deco-bounce 2s ease-in-out infinite;
}
.deco-icon:nth-child(2) {
animation-delay: 0.3s;
}
.deco-icon:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes deco-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.bottom-decoration.fade-out {
opacity: 0;
transform: translateY(30px);
}
/* 右上角音乐图标 */
.music-icon-wrapper {
position: fixed;
top: calc(20px + env(safe-area-inset-top));
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
z-index: 100;
cursor: pointer;
transition: all 0.3s ease;
}
.music-icon-wrapper:active {
transform: scale(0.95);
}
.music-icon-wrapper.playing .music-icon-emoji {
animation: music-rotate 2s linear infinite;
}
.music-icon-wrapper.fade-out {
opacity: 0;
transform: scale(0.8);
}
.music-icon-emoji {
font-size: 28px;
display: block;
transition: transform 0.3s ease;
}
@keyframes music-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

Binary file not shown.