This commit is contained in:
2026-03-06 13:41:22 +08:00
commit f39c6a705f
394 changed files with 159599 additions and 0 deletions

View File

@ -0,0 +1,773 @@
<template>
<view>
<view class="certificate-list-container" :class="{ 'empty-container': !certificateList || certificateList.length === 0 }">
<template v-if="certificateList && certificateList.length > 0">
<view v-for="(item, index) in certificateList" :key="item.id || index" class="certificate-item" @click="openModal(item, index)">
<image :src="item.certificate_url" mode="aspectFit" class="certificate-image" />
</view>
</template>
<view v-else class="certificate-placeholder">
<image :src="`${imgPrefix}certificatePlaceholder.png`" mode="aspectFit" class="placeholder-image" />
<text class="placeholder-text">暂无内容</text>
</view>
</view>
<!-- 遮罩层 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-content" @click.stop>
<scroll-view class="scroll-container" scroll-x @scroll="onScroll">
<view class="scroll-content">
<view
v-for="(item, index) in certificateList"
:key="item.id || index"
class="certificate-modal-item"
:class="{ 'first-item': index === 0, 'not-last-item': index !== certificateList.length - 1 }"
:style="{ backgroundImage: `url(${imgPrefix}certificateGround.png)` }"
>
<!-- 主要内容容器 - 居中 -->
<view class="certificate-main-content">
<!-- 证书标题 -->
<view class="certificate-title-section">
<text class="certificate-title">{{ item.title || '为爱续航证书' }}</text>
<text class="certificate-title-en">{{ item.title_en || 'Certificate of Love Endurance' }}</text>
<image class="certificate-line" :src="`${imgPrefix}certificateLine.png`" mode="aspectFit" />
</view>
<!-- 用户名称 -->
<view class="user-name-section">
<text class="user-name">{{ userName || '周佳佳' }}</text>
<text class="user-title">先生/女士</text>
</view>
<!-- 证书描述 -->
<view class="certificate-description description-text">{{ `您累计捐赠${item.source_value}克粮,为毛孩子奉献爱心,点燃希望,感谢您的捐赠让世界变得更温暖。` }}</view>
<!-- 证书结尾 -->
<view class="certificate-ending ending-text">特发此证,以表谢忱!</view>
</view>
<!-- 证书图标和编号 - 右下角 -->
<view class="certificate-footer-info">
<view class="certificate-badge">
<image :src="item.certificate_url" mode="aspectFit" class="badge-image" />
</view>
<text class="certificate-number">证书编号: {{ item.certificate_no || item.id || '234546678896666788' }}</text>
</view>
</view>
<!-- 右边距占位元素 -->
<view class="right-spacer"></view>
</view>
</scroll-view>
<!-- 计数器 -->
<view class="certificate-counter">
<text class="counter-text">{{ currentPage }}/{{ certificateList.length }}</text>
</view>
<!-- 操作按钮 -->
<view class="certificate-actions">
<button class="action-btn" @click.stop="saveCurrentImage">保存当前图片</button>
<!-- <button class="action-btn" @click.stop="shareCertificate">分享</button> -->
</view>
</view>
</view>
<!-- 隐藏的 wxml-to-canvas 组件用于生成证书图片 -->
<wxml-to-canvas class="widget" :width="canvasWidth" :height="canvasHeight" style="position: fixed; left: -9999px; top: -9999px;"></wxml-to-canvas>
</view>
</template>
<script>
import { getCertificates as getCertificatesApi } from '@/api/user';
import { imgPrefix } from '@/utils/common';
export default {
name: 'CertificateList',
data() {
return {
imgPrefix,
certificateList: [],
showModal: false,
currentItem: null,
currentIndex: 0,
currentPage: 1,
canvasWidth: 317,
canvasHeight: 224,
currentCertificateItem: null // 存储当前要生成图片的证书信息
};
},
computed: {
userName() {
// 从 Vuex store 中获取用户信息
const userInfo = this.$store.state?.user?.userInfo || {};
return userInfo.username;
}
},
onShow() {
this.getCertificates();
},
onReady() {
// 获取 wxml-to-canvas 组件实例
this.widget = this.$refs.widget || this.selectComponent('.widget');
},
methods: {
getCertificates(data) {
getCertificatesApi(data).then((res) => {
const list = res?.data;
this.certificateList = list;
});
},
openModal(item, index) {
this.currentItem = item;
this.currentIndex = index;
this.currentPage = index + 1;
this.showModal = true;
},
closeModal() {
this.showModal = false;
},
onScroll(e) {
// 获取滚动位置单位px
const scrollLeft = e.detail.scrollLeft;
// 获取系统信息,计算 rpx 到 px 的转换比例
const systemInfo = uni.getSystemInfoSync();
const rpxToPx = systemInfo.windowWidth / 750;
// 每个证书项的宽度634rpx加上间距20rpx
const itemWidth = (634 + 20) * rpxToPx;
// 计算当前显示的索引(四舍五入)
const index = Math.round(scrollLeft / itemWidth);
// 更新当前页码从1开始最大不超过列表长度
const newPage = Math.min(Math.max(index + 1, 1), this.certificateList.length);
if (newPage !== this.currentPage) {
this.currentPage = newPage;
}
},
async saveCurrentImage() {
// 保存当前图片 - 使用 wxml-to-canvas 生成
const currentIndex = this.currentPage - 1;
const currentItem = this.certificateList[currentIndex];
if (!currentItem) {
uni.showToast({
title: '证书信息加载中,请稍候',
icon: 'none'
});
return;
}
// 存储当前证书信息到 data供模板使用
this.currentCertificateItem = currentItem;
// 获取组件实例 - 多种方式尝试
if (!this.widget) {
this.widget = this.$refs.widget;
}
if (!this.widget) {
this.widget = this.selectComponent('.widget');
}
if (!this.widget) {
// 延迟重试一次
await new Promise(resolve => setTimeout(resolve, 100));
this.widget = this.$refs.widget || this.selectComponent('.widget');
}
if (!this.widget) {
uni.hideLoading();
uni.showToast({
title: '组件初始化失败,请重试',
icon: 'none',
duration: 3000
});
console.error('wxml-to-canvas 组件获取失败');
return;
}
// 检查组件方法是否可用
if (typeof this.widget.renderToCanvas !== 'function') {
uni.hideLoading();
uni.showToast({
title: '组件方法不可用,请重试',
icon: 'none',
duration: 3000
});
console.error('renderToCanvas 方法不存在');
return;
}
uni.showLoading({
title: '生成图片中...',
mask: true
});
try {
// 固定画布尺寸
const canvasWidth = 317;
const canvasHeight = 224;
// 更新 canvas 尺寸
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
await this.$nextTick();
// 生成证书的 wxml 和 style使用固定尺寸
const { wxml, style } = this.generateCertificateTemplate();
// 渲染到 canvas
const container = await this.widget.renderToCanvas({ wxml, style });
// 转换为图片
const res = await this.widget.canvasToTempFilePath({
fileType: 'png',
quality: 1,
width: this.canvasWidth,
height: this.canvasHeight
});
if (!res || !res.tempFilePath) {
throw new Error('生成图片路径失败');
}
// 保存到相册
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.hideLoading();
uni.showToast({
title: '保存成功',
icon: 'success'
});
},
fail: (err) => {
uni.hideLoading();
const errMsg = err?.errMsg || '未知错误';
uni.showToast({
title: '保存失败,请检查相册权限',
icon: 'none',
duration: 3000
});
console.error('保存图片失败:', errMsg, err);
}
});
} catch (error) {
uni.hideLoading();
const errorMsg = error?.message || error?.toString() || '未知错误';
const errorInfo = `生成图片失败: ${errorMsg}`;
console.log(errorInfo, '--=')
uni.showToast({
title: errorInfo.length > 20 ? '生成图片失败,请重试' : errorInfo,
icon: 'none',
duration: 3000
});
// 使用多种方式记录错误,确保线上环境能捕获
console.error('生成证书图片失败:', error);
console.error('错误详情:', {
message: error?.message,
stack: error?.stack,
error: error
});
// 如果 console 不可用,尝试通过其他方式记录
if (typeof uni.reportError === 'function') {
uni.reportError({
error: errorInfo,
errorInfo: JSON.stringify(error)
});
}
}
},
generateCertificateTemplate() {
// 使用存储的证书信息
const item = this.currentCertificateItem;
if (!item) {
throw new Error('证书信息不存在');
}
// 固定容器尺寸
const width = 317;
const height = 224;
// 固定内容宽度和位置(根据设计图调整)
const mainContentWidth = 250;
const mainContentLeft = (width - mainContentWidth) / 2;
// 确保数据正确转义
const title = (item.title || '为爱续航证书').replace(/"/g, '&quot;');
const titleEn = (item.title_en || 'Certificate of Love Endurance').replace(/"/g, '&quot;');
const rawUserName = this.userName || '周佳佳';
const sanitizedUserName = rawUserName.replace(/"/g, '&quot;');
const description = `您累计捐赠${item.source_value || 0}克粮,为毛孩子奉献爱心,点燃希望,感谢您的捐赠让世界变得更温暖。`.replace(/"/g, '&quot;');
const certificateNo = `证书编号: ${item.certificate_no || item.id || '234546678896666788'}`.replace(/"/g, '&quot;');
const nameLength = rawUserName.length;
const estimatedCharWidth = 18;
const minNameWidth = estimatedCharWidth * 2;
const maxNameWidth = mainContentWidth - 60;
const nameWrapperWidth = Math.min(maxNameWidth, Math.max(estimatedCharWidth * nameLength, minNameWidth));
const wxml = `
<view class="container">
<image class="bg-image" src="${this.imgPrefix}certificateGround.png"></image>
<view class="main-content">
<view class="title-section">
<text class="title">${title}</text>
<text class="title-en">${titleEn}</text>
<image class="line-image" src="${this.imgPrefix}certificateLine.png"></image>
</view>
<view class="user-section">
<view class="name-wrapper">
<text class="user-name">${sanitizedUserName}</text>
<view class="underline"></view>
</view>
<text class="user-title">先生/女士</text>
</view>
<view class="description">
<text class="description-text">${description}</text>
</view>
<view class="ending">
<text class="ending-text">特发此证,以表谢忱!</text>
</view>
</view>
<view class="footer-info">
<view class="badge">
<image class="badge-image" src="${item.certificate_url || ''}"></image>
</view>
<text class="certificate-number">${certificateNo}</text>
</view>
</view>
`;
const style = {
container: {
width: width,
height: height,
position: 'relative',
backgroundColor: '#ffffff',
left: 0,
top: 0,
overflow: 'hidden'
},
bgImage: {
width: width,
height: height,
position: 'absolute',
left: 0,
top: 0,
zIndex: 0
},
mainContent: {
width: mainContentWidth,
height: height - 100,
position: 'absolute',
left: mainContentLeft,
top: 38,
flexDirection: 'column',
alignItems: 'center'
},
titleSection: {
width: mainContentWidth,
flexDirection: 'column',
alignItems: 'center',
marginBottom: 7
},
title: {
width: mainContentWidth,
height: 25,
fontSize: 20,
color: '#FF19A0',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 20,
marginBottom: 1
},
titleEn: {
width: mainContentWidth,
height: 15,
fontSize: 8,
color: '#FF19A0',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 8,
marginBottom: 4
},
lineImage: {
width: mainContentWidth,
height: 2
},
userSection: {
width: mainContentWidth,
height: 15,
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 4
},
nameWrapper: {
width: nameWrapperWidth,
height: 15,
flexDirection: 'column',
marginRight: 2,
position: 'relative'
},
userName: {
width: nameWrapperWidth,
height: 18,
fontSize: 14,
color: '#FF19A0',
verticalAlign: 'top',
lineHeight: 14
},
underline: {
width: nameWrapperWidth,
height: 1,
backgroundColor: '#FF19A0',
position: 'absolute',
left: 0,
top: 15
},
userTitle: {
width: 50,
height: 15,
fontSize: 11,
color: '#333333',
verticalAlign: 'top',
lineHeight: 11
},
description: {
width: mainContentWidth,
height: 75,
marginBottom: 4
},
descriptionText: {
width: mainContentWidth,
height: 75,
fontSize: 11,
color: '#333333',
textAlign: 'left',
verticalAlign: 'top',
lineHeight: 16.5
},
ending: {
width: mainContentWidth,
height: 11
},
endingText: {
width: mainContentWidth,
height: 15,
fontSize: 11,
color: '#333333',
textAlign: 'left',
verticalAlign: 'top',
lineHeight: 11
},
footerInfo: {
width: 100,
height: 60,
position: 'absolute',
right: 17,
bottom: 17,
flexDirection: 'column',
alignItems: 'flex-end'
},
badge: {
width: 40,
height: 40,
marginBottom: 10
},
badgeImage: {
width: 40,
height: 40
},
certificateNumber: {
width: 100,
height: 10,
fontSize: 7,
color: '#FF19A0',
textAlign: 'right',
verticalAlign: 'top',
lineHeight: 7
}
};
return { wxml, style };
},
shareCertificate() {
// 分享证书
const currentIndex = this.currentPage - 1;
const currentItem = this.certificateList[currentIndex];
if (!currentItem) {
uni.showToast({
title: '证书信息加载中,请稍候',
icon: 'none'
});
return;
}
// 使用微信小程序的分享功能
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
});
// 或者显示分享提示
uni.showToast({
title: '请点击右上角分享',
icon: 'none',
duration: 2000
});
}
}
};
</script>
<style lang="scss" scoped>
.certificate-list-container {
margin: 20rpx;
background-color: #fff;
padding: 20rpx;
border-radius: 16rpx;
display: flex;
flex-wrap: wrap;
&.empty-container {
min-height: calc(100vh - 80rpx);
height: 100%;
}
}
.certificate-item {
flex: 0 0 calc(100% / 3);
display: flex;
justify-content: center;
}
.certificate-image {
width: 200rpx;
height: 200rpx;
}
.certificate-placeholder {
width: 100%;
min-height: 400rpx;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx 20rpx;
}
.placeholder-image {
width: 213.15px;
height: 160px;
margin-bottom: 20rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #999;
}
// 遮罩层样式
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.scroll-content{
display: flex;
}
.certificate-modal-item.not-last-item {
margin-right: 20rpx;
}
.right-spacer {
flex-shrink: 0;
width: 40rpx;
height: 1rpx;
}
.certificate-counter {
// position: absolute;
// bottom: 40rpx;
// left: 50%;
// transform: translateX(-50%);
// z-index: 1000;
margin-top: 20rpx;
}
.counter-text {
font-size: 24rpx;
color: #fff;
}
.certificate-actions {
display: flex;
margin-top: 40rpx;
justify-content: center;
}
.action-btn {
width: 152.5px;
height: 40px;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 4rpx;
background: #FF19A0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
border-radius: 400rpx;
padding: 0;
}
.action-btn:first-child {
margin-right: 24rpx;
}
.action-btn::after {
border: none;
}
.action-btn:active {
opacity: 0.8;
}
.certificate-modal-item {
width: calc(100vw - 80rpx);
height: 448rpx;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
}
.certificate-modal-item.first-item {
margin-left: 40rpx;
}
.certificate-main-content {
width: 502rpx;
position: absolute;
top: 76rpx;
left: 50%;
transform: translate(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.certificate-title-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 14rpx;
width: 100%;
}
.certificate-title {
font-size: 40rpx;
font-weight: bold;
color: #FF19A0;
margin-bottom: 2rpx;
text-align: center;
}
.certificate-title-en {
font-size: 16rpx;
color: #FF19A0;
text-align: center;
margin-bottom: 8rpx;
}
.certificate-line {
width: 100%;
height: 4rpx;
}
.user-name-section {
display: flex;
align-items: baseline;
margin-bottom: 8rpx;
width: 100%;
}
.user-name {
font-size: 28rpx;
color: #FF19A0;
margin-right: 4rpx;
text-decoration: underline;
}
.user-title {
font-size: 22rpx;
}
.certificate-description {
width: 100%;
box-sizing: border-box;
margin-bottom: 8rpx;
font-size: 22rpx;
color: #333;
text-align: left;
}
.certificate-ending {
width: 100%;
font-size: 22rpx;
color: #333;
}
.certificate-footer-info {
position: absolute;
bottom: 34rpx;
right: 34rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.certificate-badge {
display: flex;
flex-direction: column;
align-items: center;
}
.badge-image {
width: 80rpx;
height: 80rpx;
margin-bottom: 36rpx;
margin-right: 36rpx;
}
.certificate-number {
font-size: 14rpx;
color: #FF19A0;
text-align: right;
}
.modal-certificate-image {
width: 100%;
height: auto;
max-width: 600rpx;
}
</style>