696 lines
16 KiB
Vue
696 lines
16 KiB
Vue
<!-- 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>
|