Files
spring-festival-greetings/pages/avatar/index.vue
2026-01-25 23:03:26 +08:00

784 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="avatar-page" :style="{ paddingTop: getBavBarHeight() + 'px' }">
<view class="nav-bar">
<view class="back" @tap="goBack"></view>
<text class="nav-title-left">新春头像挂饰</text>
</view>
<view class="preview-card">
<view class="preview-square">
<image class="avatar-img" :src="currentAvatar" mode="aspectFill" />
<image
v-if="selectedFrame"
class="frame-img"
:src="selectedFrame"
mode="aspectFill"
/>
<image
v-if="selectedDecor"
class="decor-img"
:src="selectedDecor"
mode="aspectFit"
:style="decorStyle"
@touchstart.stop="onTouchStart"
@touchmove.stop="onTouchMove"
@touchend.stop="onTouchEnd"
/>
</view>
<view v-if="selectedDecor" class="interaction-tip">
<text>👆 单指拖动</text>
<text class="tip-divider">|</text>
<text> 双指缩放/旋转</text>
</view>
<text v-else class="preview-tip">实时预览效果</text>
</view>
<view class="action-buttons">
<button class="btn secondary" @tap="saveAndUse">保存</button>
<button class="btn primary" open-type="share">分享给朋友</button>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">选择底图</text>
<view class="more-btn" @tap="openMorePopup">
<text>查看更多</text>
<text class="arrow"></text>
</view>
</view>
<scroll-view scroll-x class="avatar-scroll" show-scrollbar="false">
<view class="avatar-list">
<view class="avatar-card upload-card" @tap="useWeChatAvatar">
<view class="upload-icon">📷</view>
<text class="upload-text">微信头像</text>
</view>
<view
v-for="(item, i) in systemAvatars"
:key="i"
class="avatar-card"
:class="{ active: currentAvatar === item }"
@tap="currentAvatar = item"
>
<image :src="item" class="avatar-thumb" mode="aspectFill" />
<view v-if="currentAvatar === item" class="check"></view>
</view>
</view>
</scroll-view>
</view>
<view class="tabs">
<view
class="tab"
:class="{ active: activeTab === 'frame' }"
@tap="activeTab = 'frame'"
>头像框</view
>
<view
class="tab"
:class="{ active: activeTab === 'decor' }"
@tap="activeTab = 'decor'"
>挂饰配件</view
>
</view>
<view v-if="activeTab === 'frame'" class="grid">
<view
v-for="(frame, i) in frames"
:key="i"
class="grid-item"
:class="{ active: selectedFrame === frame }"
@tap="toggleFrame(frame)"
>
<image :src="frame" class="grid-img" mode="aspectFill" />
<view v-if="selectedFrame === frame" class="check"></view>
</view>
</view>
<view v-else class="grid">
<view
v-for="(decor, i) in decors"
:key="i"
class="grid-item"
:class="{ active: selectedDecor === decor }"
@tap="selectedDecor = decor"
>
<image :src="decor" class="grid-img" mode="aspectFit" />
<view v-if="selectedDecor === decor" class="check"></view>
</view>
</view>
<canvas
canvas-id="avatarCanvas"
class="hidden-canvas"
style="width: 600px; height: 600px"
/>
<!-- Login Popup -->
<LoginPopup ref="loginPopupRef" @logind="handleLogind" />
<!-- More Avatar Popup -->
<uni-popup ref="morePopup" type="bottom" background-color="#fff">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">选择头像</text>
<view class="close-btn" @tap="closeMorePopup"></view>
</view>
<scroll-view
scroll-y
class="popup-scroll"
@scrolltolower="loadMoreAvatars"
>
<view class="popup-grid">
<view
v-for="(item, i) in moreAvatars"
:key="i"
class="popup-item"
@tap="selectMoreAvatar(item)"
>
<image :src="item" class="popup-img" mode="aspectFill" />
</view>
</view>
<view v-if="loading" class="loading-text">加载中...</view>
<view v-if="!hasMore && moreAvatars.length > 0" class="no-more-text">
没有更多了
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { ref, computed } from "vue";
import { onShareAppMessage, onLoad } from "@dcloudio/uni-app";
import { getBavBarHeight, getDeviceInfo } from "@/utils/system";
import { useUserStore } from "@/stores/user";
import {
createShareToken,
getShareReward,
abilityCheck,
} from "@/api/system.js";
import { avatarDownloadRecord, getAvatarSystemList } from "@/api/avatar.js";
const userStore = useUserStore();
const loginPopupRef = ref(null);
const isLoggedIn = computed(() => !!userStore.userInfo.nickName);
const systemAvatars = ref([]);
const frames = [
"https://file.lihailezzc.com/6.png",
"https://file.lihailezzc.com/7.png",
"https://file.lihailezzc.com/yunshi.png",
"https://file.lihailezzc.com/x_CURXRzG4wHF2dp_zu_r-removebg-preview.png",
];
const decors = [
"https://file.lihailezzc.com/6.png",
"https://file.lihailezzc.com/7.png",
];
const currentAvatar = ref("");
const selectedFrame = ref("");
const selectedDecor = ref("");
const activeTab = ref("frame");
// More Popup logic
const morePopup = ref(null);
const moreAvatars = ref([]);
const page = ref(1);
const hasMore = ref(true);
const loading = ref(false);
const initSystemAvatars = async () => {
try {
const res = await getAvatarSystemList(1);
const list = res?.list || [];
if (list.length > 0) {
// 取前3个展示在首页
systemAvatars.value = list
.slice(0, 3)
.map((item) => item.imageUrl);
// 默认选中第一个
if (systemAvatars.value.length > 0) {
currentAvatar.value = systemAvatars.value[0];
}
}
} catch (e) {
console.error("Failed to load system avatars:", e);
}
};
onLoad(() => {
initSystemAvatars();
});
const openMorePopup = () => {
morePopup.value.open();
if (moreAvatars.value.length === 0) {
// 重新加载第一页,因为 systemAvatars 只取了前3个
// 这里我们简单处理:重新请求第一页作为更多列表的开始
// 或者你可以复用 systemAvatars 的数据,然后 page 从 2 开始请求
// 为了逻辑简单和数据一致性,这里选择重新请求第一页填充更多列表
loadMoreAvatars();
}
};
const closeMorePopup = () => {
morePopup.value.close();
};
const loadMoreAvatars = async () => {
if (loading.value || !hasMore.value) return;
loading.value = true;
try {
const res = await getAvatarSystemList(page.value);
const list = res?.list || [];
if (list.length > 0) {
const newAvatars = list.map((item) => item.imageUrl);
moreAvatars.value.push(...newAvatars);
page.value++;
}
// 根据接口返回的 hasNext 字段判断是否还有更多数据
// 如果接口没有返回 hasNext则降级使用列表长度判断假设每页10条
if (typeof res.hasNext !== "undefined") {
hasMore.value = res.hasNext;
} else {
if (list.length < 10) {
hasMore.value = false;
}
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const selectMoreAvatar = (url) => {
currentAvatar.value = url;
closeMorePopup();
};
const toggleFrame = (frame) => {
if (selectedFrame.value === frame) {
selectedFrame.value = "";
} else {
selectedFrame.value = frame;
}
};
// 挂饰状态
const decorState = ref({
x: 300, // 初始中心 X (rpx)
y: 80, // 初始中心 Y (rpx)
scale: 1,
rotate: 0,
});
const decorStyle = computed(() => {
return {
transform: `translate(${decorState.value.x - 120}rpx, ${
decorState.value.y - 120
}rpx) rotate(${decorState.value.rotate}deg) scale(${
decorState.value.scale
})`,
};
});
// 触摸状态
let startTouches = [];
let initialDecorState = {};
const getDistance = (p1, p2) => {
const x = p1.clientX - p2.clientX;
const y = p1.clientY - p2.clientY;
return Math.sqrt(x * x + y * y);
};
const getAngle = (p1, p2) => {
const x = p1.clientX - p2.clientX;
const y = p1.clientY - p2.clientY;
return (Math.atan2(y, x) * 180) / Math.PI;
};
const onTouchStart = (e) => {
startTouches = e.touches;
initialDecorState = { ...decorState.value };
};
const onTouchMove = (e) => {
if (e.touches.length === 1 && startTouches.length === 1) {
// 单指移动
const dx = e.touches[0].clientX - startTouches[0].clientX;
const dy = e.touches[0].clientY - startTouches[0].clientY;
// px 转 rpx
const systemInfo = uni.getSystemInfoSync();
const ratio = 750 / systemInfo.windowWidth;
decorState.value.x = initialDecorState.x + dx * ratio;
decorState.value.y = initialDecorState.y + dy * ratio;
} else if (e.touches.length === 2 && startTouches.length === 2) {
// 双指缩放/旋转
const p1 = e.touches[0];
const p2 = e.touches[1];
const startP1 = startTouches[0];
const startP2 = startTouches[1];
const currentDist = getDistance(p1, p2);
const startDist = getDistance(startP1, startP2);
const currentAngle = getAngle(p1, p2);
const startAngle = getAngle(startP1, startP2);
decorState.value.scale =
initialDecorState.scale * (currentDist / startDist);
decorState.value.rotate =
initialDecorState.rotate + (currentAngle - startAngle);
}
};
const onTouchEnd = () => {
// 可以在这里做边界检查等
};
const handleLogind = async () => {
// Logic after successful login if needed
};
const useWeChatAvatar = () => {
if (!isLoggedIn.value) {
loginPopupRef.value.open();
} else {
currentAvatar.value = userStore.userInfo.avatarUrl;
}
};
const goBack = () => {
uni.navigateBack();
};
const saveAndUse = async () => {
const abilityRes = await abilityCheck("avatar_download");
if (!abilityRes.canUse) {
if (
abilityRes?.blockType === "need_share" &&
abilityRes?.message === "分享可继续"
) {
uni.showToast({
title: "分享到群聊可继续使用",
icon: "none",
});
return;
}
uni.showToast({
title: "您今日头像下载次数已用完,明日再试",
icon: "none",
});
return;
}
// 调用avatarDownloadRecord API记录下载次数
await avatarDownloadRecord({
avatarUrl: currentAvatar.value,
});
const ctx = uni.createCanvasContext("avatarCanvas");
const size = 600;
const avatarPath = await loadImage(currentAvatar.value);
ctx.clearRect(0, 0, size, size);
ctx.drawImage(avatarPath, 0, 0, size, size);
if (selectedFrame.value) {
const framePath = await loadImage(selectedFrame.value);
ctx.drawImage(framePath, 0, 0, size, size);
}
if (selectedDecor.value) {
const decorPath = await loadImage(selectedDecor.value);
ctx.save();
// 映射 rpx 坐标到 Canvas 坐标 (假设 1rpx = 1 unit for 600x600 canvas logic)
// Canvas size is 600, Preview is 600rpx. Ratio is 1:1 in logical space.
ctx.translate(decorState.value.x, decorState.value.y);
ctx.rotate((decorState.value.rotate * Math.PI) / 180);
const scale = decorState.value.scale;
// 绘制图片,宽高 240
ctx.drawImage(
decorPath,
-120 * scale,
-120 * scale,
240 * scale,
240 * scale,
);
ctx.restore();
}
ctx.draw(false, () => {
uni.canvasToTempFilePath({
canvasId: "avatarCanvas",
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ title: "已保存到相册", icon: "success" });
},
});
},
});
});
};
const share = () => {
uni.showToast({ title: "已生成,可在相册分享", icon: "none" });
};
onShareAppMessage(async () => {
const deviceInfo = getDeviceInfo();
const shareTokenRes = await createShareToken({
targetId: "",
scene: "avatar_download",
...deviceInfo,
});
getRewardByShare();
return {
title: "制作我的新春头像",
path: `/pages/avatar/index?shareToken=${shareTokenRes.shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/b48c41054c2633c478463ac1b1f1ca23.png", // 使用默认封面或 popularCards 的封面
};
});
const getRewardByShare = async () => {
const res = await getShareReward({ scene: "avatar_download" });
if (res.success) {
uni.showToast({ title: "分享成功,可下载头像" });
checkDrawStatus();
}
};
// onShareTimeline(() => {
// return {
// title: "制作我的新春头像",
// imageUrl:
// "https://file.lihailezzc.com/resource/b48c41054c2633c478463ac1b1f1ca23.png",
// };
// });
const loadImage = (url) => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: (res) => resolve(res.path),
fail: reject,
});
});
};
</script>
<style lang="scss" scoped>
.avatar-page {
min-height: 100vh;
background: #fff;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
}
.back {
font-size: 40rpx;
margin-right: 12rpx;
}
.nav-title-left {
font-size: 32rpx;
font-weight: 600;
}
.preview-card {
margin: 20rpx 24rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.06);
}
.preview-square {
width: 600rpx;
height: 600rpx;
margin: 0 auto;
border-radius: 24rpx;
background: #f5dfc9;
position: relative;
overflow: hidden;
}
.avatar-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.frame-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.decor-img {
position: absolute;
top: 0;
left: 0;
width: 240rpx;
height: 240rpx;
/* 移除原有的 transform 和 center positioning改为由 inline style 控制 */
}
.preview-tip {
display: block;
text-align: center;
color: #999;
font-size: 22rpx;
margin-top: 12rpx;
}
.interaction-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 20rpx;
font-size: 24rpx;
color: #ff3b30;
background: #fff0f0;
padding: 12rpx 32rpx;
border-radius: 999rpx;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.tip-divider {
color: #ffccc7;
margin: 0 4rpx;
}
.action-buttons {
display: flex;
padding: 0 48rpx;
gap: 32rpx;
margin-top: 32rpx;
}
.btn {
flex: 1;
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
margin: 0;
padding: 0;
border: none;
line-height: normal;
}
.btn::after {
border: none;
}
.section {
margin-top: 24rpx;
padding: 0 24rpx;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.section-title {
font-size: 26rpx;
font-weight: 600;
}
.avatar-scroll {
width: 100%;
}
.avatar-list {
display: flex;
}
.avatar-card {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
margin-right: 16rpx;
position: relative;
background: #fff;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06);
}
.avatar-card.active {
outline: 4rpx solid #ff3b30;
}
.upload-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff5f5;
border: 2rpx dashed #ffccc7;
box-sizing: border-box;
}
.upload-icon {
font-size: 48rpx;
margin-bottom: 8rpx;
}
.upload-text {
font-size: 22rpx;
color: #ff3b30;
font-weight: 500;
}
.avatar-thumb {
width: 100%;
height: 100%;
}
.check {
position: absolute;
right: 8rpx;
top: 8rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: #ff3b30;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
}
.tabs {
display: flex;
padding: 16rpx 24rpx 8rpx;
gap: 12rpx;
}
.tab {
flex: 1;
text-align: center;
background: #f7f7f7;
border-radius: 999rpx;
height: 64rpx;
line-height: 64rpx;
font-size: 26rpx;
color: #666;
}
.tab.active {
background: #fff0f0;
color: #ff3b30;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding: 8rpx 24rpx 24rpx;
}
.grid-item {
height: 200rpx;
border-radius: 16rpx;
background: #fff;
overflow: hidden;
position: relative;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06);
}
.grid-item.active {
outline: 4rpx solid #ff3b30;
}
.grid-img {
width: 100%;
height: 100%;
}
.btn.primary {
background: #ff3b30;
color: #fff;
box-shadow: 0 12rpx 24rpx rgba(255, 59, 48, 0.35);
}
.btn.secondary {
background: #f5f5f5;
color: #333;
}
.hidden-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
/* More Popup Styles */
.more-btn {
margin-left: auto;
font-size: 24rpx;
color: #999;
display: flex;
align-items: center;
}
.arrow {
font-size: 32rpx;
margin-left: 4rpx;
line-height: 1;
position: relative;
top: -2rpx;
}
.popup-content {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
height: 60vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup-header {
padding: 32rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-bottom: 2rpx solid #f5f5f5;
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
}
.close-btn {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
font-size: 32rpx;
color: #999;
padding: 10rpx;
}
.popup-scroll {
flex: 1;
height: 0;
}
.popup-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
padding: 24rpx;
}
.popup-item {
aspect-ratio: 1;
border-radius: 16rpx;
overflow: hidden;
background: #f5f5f5;
}
.popup-img {
width: 100%;
height: 100%;
}
.loading-text,
.no-more-text {
text-align: center;
padding: 24rpx;
color: #999;
font-size: 24rpx;
}
</style>