634 lines
17 KiB
Vue
634 lines
17 KiB
Vue
<template>
|
||
<view class="honor-of-kings">
|
||
<nav-bar title="和平精英" bgColor="#F5F5F5" isRightButton :rightButtonText="rightButtonText"
|
||
@right-click="onRightClick">
|
||
</nav-bar>
|
||
<view style="padding: 8px;box-sizing: border-box;">
|
||
<view class="painter-container" @click="handlePreview"
|
||
:style="`width:calc(100vw - 16px) ; height: ${posterContainerHeight}px; overflow: hidden; position: relative; transform: translateZ(0);`">
|
||
<!-- 运用 js 算出的无误差纯数字 scale 来实现高清画板的等比缩小,彻底兼容所有老旧内核,不再被截断 -->
|
||
<view
|
||
:style="`width: 822px; transform: scale(${posterScaleRatio}); transform-origin: left top; position: absolute; left: 0; top: 0;`">
|
||
<l-painter v-if="bgImage" isCanvasToTempFilePath @success="onPainterSuccess" :css="`width:822px;`">
|
||
<l-painter-view :css="`width: 822px; position: relative;`">
|
||
<!-- 直接使用主背景图撑开父容器,移除不必要的 opacity: 0 占位图,避免在大屏渲染时产生黑块 -->
|
||
<l-painter-image :src="bgImage" css="width: 822px; display: block;"></l-painter-image>
|
||
|
||
<!-- 人物图片 -->
|
||
<l-painter-image :src="hePingData.imgUrl" :css="characterStyle"></l-painter-image>
|
||
<template v-if="hePingData.type == 2">
|
||
<!-- 头像 -->
|
||
<l-painter-image :src="hePingData.avatar"
|
||
css="position:absolute;left:50px;bottom:13px;width:36px;height:36px"></l-painter-image>
|
||
<!-- 头像框 -->
|
||
<l-painter-image src="/static/image/other/game/heping/touxiang.png"
|
||
css="position:absolute;left:0;bottom:0;width:100px;height:60px"></l-painter-image>
|
||
<!-- 昵称 -->
|
||
<l-painter-text :text="hePingData.nickname"
|
||
css="position: absolute; left: 102px; bottom: 15px; font-size: 10px; color: #EFF9FF;"></l-painter-text>
|
||
</template>
|
||
|
||
<!-- 水印 -->
|
||
<l-painter-image v-if="$isVip()" src="/static/image/other/shuiying.png"
|
||
:css="`position: absolute; left: 18.8px; bottom: 17.1px; width: 145.38px;height:42.76px`"></l-painter-image>
|
||
</l-painter-view>
|
||
</l-painter>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 水印 -->
|
||
<view v-if="$isVip()">
|
||
<liu-drag-button :canDocking="false"
|
||
@clickBtn="$goRechargePage('watermark', 'uni_alipay_other_game_heping')">
|
||
<c-lottie ref="cLottieRef" :src='$watermark()' width="94px" height='74px' :loop="true"></c-lottie>
|
||
</liu-drag-button>
|
||
</view>
|
||
|
||
<view class="save-action">
|
||
<button class="save-btn" @click="handleSave">保存</button>
|
||
</view>
|
||
|
||
<!-- 主题选择区域 -->
|
||
<view class="theme-selector">
|
||
<scroll-view scroll-x="true" class="theme-scroll" :show-scrollbar="false">
|
||
<view class="theme-list">
|
||
<view class="theme-item" v-for="(item, index) in ['样式一', '样式二']" :key="index + 1"
|
||
:class="{ active: hePingData.type == index + 1 }" @click="handleChangeTheme(index + 1)">
|
||
<text class="theme-text">{{ item }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 数据编辑弹窗 -->
|
||
<uni-popup ref="editPopup" type="center">
|
||
<view class="edit-popup-content">
|
||
<view class="popup-header">
|
||
<text class="title">编辑主页数据</text>
|
||
</view>
|
||
<scroll-view scroll-y class="popup-scroll">
|
||
<view class="form-item avatar-form-item">
|
||
<text class="label">人物图片</text>
|
||
<view class=" avatar-uploader img-box" @click="handleChooseImgUrl">
|
||
<view class="avatar-preview" v-if="tempData.imgUrl">
|
||
<image class="preview-img" :src="tempData.imgUrl" mode="widthFix"></image>
|
||
<view class="delete-icon" @click.stop="handleDeleteImgUrl">
|
||
<text>×</text>
|
||
</view>
|
||
</view>
|
||
<view class="upload-btn" v-else>
|
||
<text class="plus">+</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="form-item avatar-form-item" v-if="tempData.type == 2">
|
||
<text class="label">头像</text>
|
||
<view class="avatar-uploader" @click="handleChooseAvatar">
|
||
<view class="avatar-preview" v-if="tempData.avatar">
|
||
<image class="preview-img" :src="tempData.avatar" mode="aspectFill"></image>
|
||
<view class="delete-icon" @click.stop="handleDeleteAvatar">
|
||
<text>×</text>
|
||
</view>
|
||
</view>
|
||
<view class="upload-btn" v-else @click="handleChooseAvatar">
|
||
<text class="plus">+</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="form-item" v-if="tempData.type == 2">
|
||
<text class="label">昵称</text>
|
||
<input class="input" type="text" v-model="tempData.nickname" />
|
||
</view>
|
||
</scroll-view>
|
||
<view class="popup-footer">
|
||
<button class="cancel-btn" @click="closeEditPopup">取消</button>
|
||
<button class="confirm-btn" @click="confirmEdit">确定</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<!-- 横向全屏放大预览层 -->
|
||
<view class="preview-overlay" v-if="showPreview" @click="showPreview = false">
|
||
<image class="preview-image" :src="finalPosterPath" mode="aspectFill"></image>
|
||
</view>
|
||
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, watch, onMounted, getCurrentInstance } from 'vue'
|
||
import { onLoad, onReady, onUnload } from '@dcloudio/uni-app'
|
||
import { imgLocal } from '@/utils/common.js';
|
||
import NavBar from '@/components/nav-bar/nav-bar.vue';
|
||
const instance = getCurrentInstance();
|
||
const { proxy } = getCurrentInstance();
|
||
|
||
// 页面表单数据字段
|
||
const hePingData = reactive({
|
||
type: 1, // 样式类型 1.2
|
||
avatar: '/static/image/shopping/pdd/avatars/avatars1.jpg', // 头像
|
||
imgUrl: '/static/image/other/game/heping/default.png', // 人物图片
|
||
nickname: '甜喵小贝', // 昵称
|
||
})
|
||
|
||
const characterImgWidth = ref(300); // 初始宽度兜底
|
||
|
||
const characterStyle = computed(() => {
|
||
if (hePingData.type == 1) {
|
||
return `position: absolute; left: 50%; bottom:43%; transform: translate(-50%,50%); height: 300px; width: 300px;`;
|
||
} else {
|
||
return `position: absolute; left: 300px; bottom: 50%;transform: translate(-50%,50%); height: 300px; width: 300px;`;
|
||
}
|
||
});
|
||
|
||
// 监听人物图片变化,动态获取其原始宽高并计算比例宽度,解决画布必须写明尺寸才能渲染的问题
|
||
watch(() => hePingData.imgUrl, (newUrl) => {
|
||
if (!newUrl) return;
|
||
uni.getImageInfo({
|
||
src: newUrl,
|
||
success: (res) => {
|
||
// 固定高度 300px,宽度等比例拉伸
|
||
const calcWidth = (300 / res.height) * res.width;
|
||
characterImgWidth.value = Math.round(calcWidth);
|
||
},
|
||
fail: () => {
|
||
characterImgWidth.value = 300; // 获取失败提供兜底宽度
|
||
}
|
||
});
|
||
}, { immediate: true });
|
||
|
||
const bgImage = ref('')
|
||
|
||
// 获取系统宽度计算海报缩放比例,代替容易失效的 css calc(),确保全端兼容防截断
|
||
const sysInfo = uni.getSystemInfoSync();
|
||
const windowWidth = sysInfo.windowWidth || 375;
|
||
// 扣除左右共 16px 的 padding,再基于实际画板宽度 822 计算精确缩放比,防止右侧被截断
|
||
const posterScaleRatio = (windowWidth - 16) / 822;
|
||
const posterContainerHeight = 374 * posterScaleRatio;
|
||
|
||
onLoad(async () => {
|
||
const cachedData = uni.getStorageSync('hePingData');
|
||
if (cachedData) {
|
||
Object.assign(hePingData, cachedData);
|
||
// 兼容处理:强制把历史缓存里的 .jpg 替换为最新的 .png,防止 404
|
||
if (hePingData.imgUrl === '/static/image/other/game/heping/default.jpg') {
|
||
hePingData.imgUrl = '/static/image/other/game/heping/default.png';
|
||
uni.setStorageSync('hePingData', hePingData);
|
||
}
|
||
}
|
||
try {
|
||
bgImage.value = await imgLocal.getLocalImage(`other/game/heping/bg-${hePingData.type}.jpg`);
|
||
} catch (e) {
|
||
console.error('获取背景图失败:', e);
|
||
uni.showToast({ title: '背景图加载失败', icon: 'none' });
|
||
}
|
||
|
||
uni.$on('editFormPhoto', (info) => {
|
||
if (currentEditImgField.value) {
|
||
tempData[currentEditImgField.value] = info;
|
||
}
|
||
// #ifndef H5
|
||
if (info && !info.startsWith('/static/')) {
|
||
newAvatars.value.push(info);
|
||
}
|
||
// #endif
|
||
});
|
||
|
||
// 进入页面埋点
|
||
if (proxy && proxy.$apiUserEvent) {
|
||
proxy.$apiUserEvent('all', {
|
||
type: 'click',
|
||
key: 'heping',
|
||
prefix: '.uni.other.',
|
||
value: "和平精英"
|
||
});
|
||
}
|
||
})
|
||
|
||
onUnload(() => {
|
||
uni.$off('editFormPhoto');
|
||
})
|
||
|
||
const rightButtonText = ref("编辑");
|
||
const finalPosterPath = ref('');
|
||
const showPreview = ref(false); // 控制全屏预览
|
||
|
||
const editPopup = ref(null);
|
||
const tempData = reactive({});
|
||
const newAvatars = ref([]);
|
||
const currentEditImgField = ref('');
|
||
|
||
function onRightClick() {
|
||
Object.assign(tempData, JSON.parse(JSON.stringify(hePingData)));
|
||
newAvatars.value = [];
|
||
editPopup.value.open();
|
||
}
|
||
|
||
const handleChooseAvatar = () => {
|
||
currentEditImgField.value = 'avatar';
|
||
uni.chooseImage({
|
||
count: 1,
|
||
success: (res) => {
|
||
uni.navigateTo({
|
||
url: `/pages/other/qf-image/qf-image?src=${res.tempFilePaths[0]}&width=200&height=200`
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleChooseImgUrl = () => {
|
||
currentEditImgField.value = 'imgUrl';
|
||
uni.chooseImage({
|
||
count: 1,
|
||
success: (res) => {
|
||
if (res.tempFilePaths[0]) {
|
||
// 跳转到 qf-image 组件进行 300x300 尺寸(传参150)的裁剪,并通过 isMatting=1 触发后续抠图
|
||
uni.navigateTo({
|
||
url: `/pages/other/qf-image/qf-image?src=${res.tempFilePaths[0]}&width=150&height=150&isMatting=1`
|
||
});
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleDeleteAvatar = () => {
|
||
tempData.avatar = '';
|
||
};
|
||
|
||
const handleDeleteImgUrl = () => {
|
||
tempData.imgUrl = '';
|
||
};
|
||
|
||
function closeEditPopup() {
|
||
// #ifndef H5
|
||
newAvatars.value.forEach(path => {
|
||
uni.removeSavedFile({ filePath: path });
|
||
});
|
||
// #endif
|
||
editPopup.value.close();
|
||
}
|
||
|
||
function confirmEdit() {
|
||
// #ifndef H5
|
||
// 删除多余的新上传文件
|
||
newAvatars.value.forEach(path => {
|
||
if (path !== tempData.avatar && path !== tempData.imgUrl) {
|
||
uni.removeSavedFile({ filePath: path });
|
||
}
|
||
});
|
||
// 删除被替换掉的原本地头像
|
||
if (hePingData.avatar !== tempData.avatar && hePingData.avatar && !hePingData.avatar.startsWith('/static/')) {
|
||
uni.removeSavedFile({ filePath: hePingData.avatar });
|
||
}
|
||
if (hePingData.imgUrl !== tempData.imgUrl && hePingData.imgUrl && !hePingData.imgUrl.startsWith('/static/')) {
|
||
uni.removeSavedFile({ filePath: hePingData.imgUrl });
|
||
}
|
||
// #endif
|
||
|
||
Object.assign(hePingData, tempData);
|
||
uni.setStorageSync('hePingData', hePingData);
|
||
editPopup.value.close();
|
||
|
||
// 数据修改后,重新渲染海报
|
||
finalPosterPath.value = '';
|
||
let tempBg = bgImage.value;
|
||
bgImage.value = ''; // 强制卸载旧画板,确保能重新渲染和生成新海报
|
||
setTimeout(() => {
|
||
bgImage.value = tempBg;
|
||
}, 50);
|
||
}
|
||
|
||
async function handleChangeTheme(typeIndex) {
|
||
// 切换主题时,先清空旧海报,触发界面“加载中”提示
|
||
finalPosterPath.value = '';
|
||
bgImage.value = ''; // 强制卸载旧画板,确保能重新渲染和生成新海报
|
||
hePingData.type = typeIndex;
|
||
uni.setStorageSync('hePingData', hePingData);
|
||
try {
|
||
bgImage.value = await imgLocal.getLocalImage(`other/game/heping/bg-${typeIndex}.jpg`);
|
||
} catch (e) {
|
||
console.error('切换主题加载背景图失败:', e);
|
||
uni.showToast({ title: '背景加载失败', icon: 'none' });
|
||
}
|
||
}
|
||
|
||
// 点击画布触发横向预览
|
||
const handlePreview = () => {
|
||
if (!finalPosterPath.value) {
|
||
uni.showToast({ title: '海报仍在生成中,请稍候', icon: 'none' });
|
||
return;
|
||
}
|
||
showPreview.value = true;
|
||
}
|
||
|
||
// 画布生成成功后触发,保存最终的海报路径
|
||
const onPainterSuccess = (path) => {
|
||
finalPosterPath.value = path;
|
||
}
|
||
|
||
// 点击保存按钮,将带有渐变字体的最终图片存入相册
|
||
const handleSave = () => {
|
||
if (!finalPosterPath.value) {
|
||
uni.showToast({ title: '图片仍在生成中,请稍候', icon: 'none' })
|
||
return
|
||
}
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: finalPosterPath.value,
|
||
success: () => {
|
||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||
},
|
||
fail: () => {
|
||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 将本地图片路径通过 Canvas 转换为 File 对象
|
||
* @param {string} localPath - 本地图片路径(如从 uni.chooseImage 获取的 tempFilePath)
|
||
* @param {Object} options - 可选参数
|
||
* @param {string} options.format - 输出格式 'image/png' 或 'image/jpeg',默认 'image/png'
|
||
* @param {number} options.quality - 图片质量(仅 jpeg 有效),0~1,默认 0.92
|
||
* @param {number} options.maxWidth - 最大宽度(等比缩放),不填则使用原图尺寸
|
||
* @param {number} options.maxHeight - 最大高度(等比缩放),不填则使用原图尺寸
|
||
* @returns {Promise<File>} 返回一个 Promise,resolve 为 File 对象
|
||
*/
|
||
function convertLocalImageToFile(localPath) {
|
||
return new Promise((resolve, reject) => {
|
||
// 1. 读取本地图片为 Base64(避免 Image 对象直接加载本地路径的兼容问题)
|
||
plus.io.resolveLocalFileSystemURL(localPath, (entry) => {
|
||
entry.file((file) => {
|
||
const reader = new plus.io.FileReader();
|
||
reader.onload = (e) => {
|
||
const base64Data = e.target.result; // 格式如 data:image/jpeg;base64,/9j/...
|
||
resolve(e.target.result)
|
||
};
|
||
reader.onerror = (err) => reject(err);
|
||
reader.readAsDataURL(file); // 读取为 DataURL
|
||
}, reject);
|
||
}, reject);
|
||
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.preview-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #000;
|
||
z-index: 99999;
|
||
/* 抛弃 flex 布局,防止 width: 100vh 被父级限制而发生坍缩 */
|
||
}
|
||
|
||
.preview-overlay .preview-image {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 100vh;
|
||
height: 100vw;
|
||
/* 先拉回自身中心点,再围绕中心点旋转 90 度 */
|
||
transform: translate(-50%, -50%) rotate(90deg);
|
||
}
|
||
|
||
.honor-of-kings {
|
||
width: 100%;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.painter-container {
|
||
/* 强制画板缩放到设备屏幕宽度,避免物理 px 超出屏幕 */
|
||
padding: 8px;
|
||
zoom: calc(100vw / 798);
|
||
width: 100%;
|
||
max-width: 1000px;
|
||
/* 限制PC/iPad端的最大宽度 */
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.save-action {
|
||
margin-top: 60rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding-bottom: 220rpx;
|
||
/* 留出底部主题固定栏的安全空间 */
|
||
|
||
.save-btn {
|
||
margin-top: 60rpx;
|
||
width: 316rpx;
|
||
background: #1777FF;
|
||
color: #fff;
|
||
border-radius: 56rpx;
|
||
}
|
||
|
||
}
|
||
|
||
.theme-selector {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
padding-top: 20rpx;
|
||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||
z-index: 99;
|
||
|
||
.theme-scroll {
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
|
||
::-webkit-scrollbar {
|
||
display: none;
|
||
width: 0 !important;
|
||
height: 0 !important;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
}
|
||
}
|
||
|
||
.theme-list {
|
||
display: inline-flex;
|
||
width: 100%;
|
||
justify-content: center;
|
||
padding: 0 20rpx;
|
||
|
||
.theme-item {
|
||
display: inline-flex;
|
||
vertical-align: top;
|
||
margin: 0 10rpx;
|
||
padding: 0 40rpx;
|
||
height: 84rpx;
|
||
border-radius: 12rpx;
|
||
background-color: #FFFFFF;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border: 2rpx solid transparent;
|
||
box-sizing: border-box;
|
||
transition: all 0.3s;
|
||
|
||
&.active {
|
||
background-color: #fff;
|
||
border-color: #3B7BFF;
|
||
|
||
.theme-text {
|
||
color: #3B7BFF;
|
||
font-weight: bold;
|
||
}
|
||
}
|
||
|
||
.theme-text {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.edit-popup-content {
|
||
background-color: #fff;
|
||
border-radius: 20rpx;
|
||
width: 85vw;
|
||
|
||
.popup-header {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 40rpx 0 20rpx 0;
|
||
|
||
.title {
|
||
font-size: 34rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.popup-scroll {
|
||
max-height: 55vh;
|
||
padding: 20rpx 40rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-item {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
padding-bottom: 16rpx;
|
||
|
||
.label {
|
||
width: 200rpx;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.avatar-uploader {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.upload-btn {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
border-radius: 50%;
|
||
background-color: #999DA7;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
|
||
.plus {
|
||
font-size: 60rpx;
|
||
color: #fff;
|
||
font-weight: 300;
|
||
margin-top: -6rpx;
|
||
}
|
||
}
|
||
|
||
|
||
.avatar-preview {
|
||
position: relative;
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
|
||
.preview-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.delete-icon {
|
||
position: absolute;
|
||
top: -4rpx;
|
||
right: -4rpx;
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
background-color: red;
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-size: 30rpx;
|
||
line-height: 28rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.img-box {
|
||
width: 200rpx !important;
|
||
|
||
.avatar-preview {
|
||
width: 200rpx !important;
|
||
height: auto !important;
|
||
}
|
||
|
||
.preview-img {
|
||
width: 100%;
|
||
border-radius: 0 !important;
|
||
}
|
||
}
|
||
|
||
.input {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
background-color: #F7F7F7;
|
||
border-radius: 12rpx;
|
||
height: 70rpx;
|
||
line-height: 70rpx;
|
||
padding: 0 20rpx;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.popup-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 20rpx 40rpx 40rpx;
|
||
|
||
button {
|
||
width: 46%;
|
||
height: 76rpx;
|
||
line-height: 76rpx;
|
||
font-size: 30rpx;
|
||
border-radius: 12rpx;
|
||
margin: 0;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.cancel-btn {
|
||
background-color: #F4F4F4;
|
||
color: #666;
|
||
}
|
||
|
||
.confirm-btn {
|
||
background-color: #3B7BFF;
|
||
color: #fff;
|
||
}
|
||
}
|
||
}
|
||
</style>
|