Files
spring-festival-greetings/pages/avatar/download.vue
2026-02-25 00:25:56 +08:00

610 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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-download-page">
<NavBar title="精选头像" />
<!-- Category Tabs -->
<view class="category-tabs">
<scroll-view scroll-x class="tabs-scroll" :show-scrollbar="false">
<view class="tabs-content">
<view
v-for="(item, index) in categories"
:key="index"
class="tab-item"
:class="{ active: currentCategoryId === item.id }"
@tap="switchCategory(item.id)"
>
{{ item.name }}
</view>
</view>
</scroll-view>
</view>
<!-- Points Info -->
<view class="points-bar">
<view class="points-left">
<uni-icons type="download" size="14" color="#ff9800" />
<text>每次下载消耗 {{ downloadCost }} 积分</text>
</view>
<view class="points-right">
<text>当前积分</text>
<text class="score-val">{{ userPoints }}</text>
</view>
</view>
<!-- Avatar Grid -->
<scroll-view
scroll-y
class="avatar-scroll"
@scrolltolower="loadMore"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<view class="grid-container">
<view class="grid-item" v-for="(item, index) in avatars" :key="index">
<image
:src="getThumbUrl(item.imageUrl)"
mode="aspectFill"
class="avatar-img"
@tap="previewImage(index)"
/>
<view class="action-overlay">
<button
class="action-btn share"
open-type="share"
:data-item="item"
@tap.stop="shareAvatar(item)"
>
<text class="icon"></text>
</button>
<view class="action-btn download" @tap.stop="downloadAvatar(item)">
<text class="icon"></text>
</view>
</view>
</view>
</view>
<!-- Loading State -->
<view class="loading-state" v-if="loading">
<text>加载中...</text>
</view>
<view class="empty-state" v-if="!loading && avatars.length === 0">
<text>暂无内容</text>
</view>
<view class="no-more" v-if="!loading && !hasMore && avatars.length > 0">
<text>没有更多了</text>
</view>
<!-- Spacer for bottom button -->
<view class="bottom-spacer"></view>
</scroll-view>
<!-- Bottom Button -->
<view class="bottom-action-bar">
<button class="create-btn" @tap="goToMake">
<uni-icons
type="compose"
size="20"
color="#fff"
style="margin-right: 8rpx"
/>
去制作专属头像
</button>
</view>
<LoginPopup
ref="loginPopupRef"
@logind="handleLogind"
:share-token="shareToken"
/>
</view>
</template>
<script setup>
import { ref, computed } from "vue";
import {
getAvatarSystemCategoryList,
getAvatarSystemListByCategory,
avatarDownloadRecord,
} from "@/api/avatar.js";
import {
saveRemoteImageToLocal,
saveRecordRequest,
getShareToken,
trackRecord,
saveViewRequest,
} from "@/utils/common.js";
import { onShareAppMessage, onShareTimeline, onLoad } from "@dcloudio/uni-app";
import { getShareReward, abilityCheck, watchAdReward } from "@/api/system.js";
import { useUserStore } from "@/stores/user";
import NavBar from "@/components/NavBar/NavBar.vue";
let videoAd = null;
const userStore = useUserStore();
const loginPopupRef = ref(null);
const isLoggedIn = computed(() => !!userStore.userInfo.nickName);
const userPoints = computed(() => userStore.userInfo.points || 0);
const downloadCost = ref(20);
const categories = ref([]);
const currentCategoryId = ref(null);
const avatars = ref([]);
const page = ref(1);
const loading = ref(false);
const hasMore = ref(true);
const isRefreshing = ref(false);
const shareToken = ref("");
onShareAppMessage(async (options) => {
if (!isLoggedIn.value) {
const shareToken = await getShareToken("avatar_download_index", "");
return {
title: "新年好运已送达 🎊|祝福卡·头像·壁纸",
path: `/pages/index/index?shareToken=${shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
};
}
getShareReward({ scene: "avatar_download" });
if (options.from === "button") {
const shareToken = await getShareToken(
"avatar_download",
options?.target?.dataset?.item?.id,
);
return {
title: "快来挑选喜欢的新春头像吧",
path: `/pages/avatar/download?shareToken=${shareToken}`,
};
} else {
const shareToken = await getShareToken("avatar_download_index", "");
return {
title: "新年好运已送达 🎊|祝福卡·头像·壁纸",
path: `/pages/index/index?shareToken=${shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
};
}
});
onShareTimeline(async () => {
const shareToken = await getShareToken("avatar_timeline");
return {
title: "精选新年头像,定制专属祝福 🧧",
query: `shareToken=${shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
};
});
onLoad((options) => {
if (options.shareToken) {
shareToken.value = options.shareToken;
saveViewRequest(options.shareToken, "avatar_download");
}
fetchCategories();
trackRecord({
eventName: "avatar_download_page_visit",
eventType: `visit`,
});
// Initialize Rewarded Video Ad
if (uni.createRewardedVideoAd) {
videoAd = uni.createRewardedVideoAd({
adUnitId: "adunit-d7a28e0357d98947",
});
videoAd.onLoad(() => {
console.log("ad loaded");
});
videoAd.onError((err) => {
console.error("ad load error", err);
});
videoAd.onClose((res) => {
console.log(1212121212, res);
if (res && res.isEnded) {
handleAdReward();
} else {
// Playback not completed
uni.showToast({ title: "观看完整广告才能获取积分哦", icon: "none" });
}
});
}
});
const getThumbUrl = (url) => {
// Use square thumbnail for avatars
return `${url}?imageView2/1/w/340/h/340/q/80`;
};
const fetchCategories = async () => {
try {
const res = await getAvatarSystemCategoryList();
const list = Array.isArray(res) ? res : res?.list || [];
if (list.length > 0) {
categories.value = list;
currentCategoryId.value = list[0].id;
loadAvatars(true);
}
} catch (e) {
console.error("Failed to fetch categories", e);
uni.showToast({ title: "获取分类失败", icon: "none" });
}
};
const switchCategory = (id) => {
if (currentCategoryId.value === id) return;
currentCategoryId.value = id;
loadAvatars(true);
trackRecord({
eventName: "avatar_category_click",
eventType: `select`,
elementId: id || "",
});
};
const loadAvatars = async (reset = false) => {
if (loading.value) return;
if (!currentCategoryId.value) return;
if (reset) {
page.value = 1;
hasMore.value = true;
avatars.value = [];
}
if (!hasMore.value) return;
loading.value = true;
try {
const res = await getAvatarSystemListByCategory(
currentCategoryId.value,
page.value,
);
const list = res?.list || [];
hasMore.value = !!res?.hasNext;
if (reset) {
avatars.value = list;
} else {
avatars.value = [...avatars.value, ...list];
}
if (hasMore.value) {
page.value++;
}
} catch (e) {
console.error("Failed to fetch avatars", e);
uni.showToast({ title: "获取列表失败", icon: "none" });
} finally {
loading.value = false;
isRefreshing.value = false;
}
};
const loadMore = () => {
loadAvatars();
};
const handleLogind = async () => {
// Logic after successful login if needed
};
const onRefresh = () => {
isRefreshing.value = true;
loadAvatars(true);
};
const previewImage = (index) => {
const urls = avatars.value.map((item) => item.imageUrl);
uni.previewImage({
urls,
current: index,
});
const item = avatars.value[index];
trackRecord({
eventName: "avatar_preview_click",
eventType: `select`,
elementId: item?.id || "",
});
};
const showRewardAd = () => {
if (videoAd) {
videoAd.show().catch(() => {
// Failed to load, try loading again
videoAd
.load()
.then(() => videoAd.show())
.catch((err) => {
console.error("Ad show failed", err);
uni.showToast({ title: "广告加载失败,请稍后再试", icon: "none" });
});
});
} else {
uni.showToast({ title: "当前环境不支持广告", icon: "none" });
}
};
const handleAdReward = async () => {
try {
const res = await watchAdReward();
if (res) {
uni.showToast({
title: "获得50积分",
icon: "success",
});
await userStore.fetchUserAssets();
}
} catch (e) {
console.error("Reward claim failed", e);
uni.showToast({ title: "奖励发放失败", icon: "none" });
}
};
const downloadAvatar = async (item) => {
trackRecord({
eventName: "avatar_download_click",
eventType: `click`,
elementId: item?.id || "",
});
if (!isLoggedIn.value) {
loginPopupRef.value.open();
return;
}
// Use avatar specific ability check if exists, otherwise reuse generic or skip?
// Assuming 'avatar_download' ability check exists or similar
const abilityRes = await abilityCheck("avatar_download");
if (!abilityRes.canUse) {
if (
abilityRes?.blockType === "need_share" &&
abilityRes?.message === "分享可继续"
) {
uni.showToast({
title: "分享给好友即可下载",
icon: "none",
});
return;
}
if (
abilityRes?.blockType === "need_ad" &&
abilityRes?.message === "观看广告可继续"
) {
uni.showModal({
title: "积分不足",
content: "观看广告可获得50积分继续下载",
success: (res) => {
if (res.confirm) {
showRewardAd();
}
},
});
return;
}
uni.showToast({
title: "您今日下载次数已用完,明日再试",
icon: "none",
});
return;
}
uni.showLoading({ title: "下载中..." });
try {
await Promise.all([
saveRemoteImageToLocal(item.imageUrl),
avatarDownloadRecord({ id: item.id, url: item.imageUrl }),
]);
await userStore.fetchUserAssets();
uni.showToast({ title: "保存成功 消耗 20 积分", icon: "success" });
} catch (e) {
console.error("Download failed", e);
// saveRemoteImageToLocal handles its own error toast usually, but let's be safe
} finally {
uni.hideLoading();
}
};
const shareAvatar = (item) => {};
const goToMake = () => {
uni.navigateTo({
url: "/pages/avatar/index",
});
};
</script>
<style lang="scss" scoped>
.avatar-download-page {
height: 100vh;
background-color: #ffffff;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.category-tabs {
padding: 0;
background-color: #ffffff;
border-bottom: 1rpx solid #eeeeee;
position: sticky;
top: 0;
z-index: 100;
}
.points-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 30rpx;
background-color: #fffbf0;
font-size: 24rpx;
color: #666;
}
.points-left {
display: flex;
align-items: center;
gap: 8rpx;
color: #ff9800;
}
.points-right {
display: flex;
align-items: center;
}
.score-val {
color: #d81e06;
font-weight: bold;
font-size: 28rpx;
margin-left: 4rpx;
}
.tabs-scroll {
white-space: nowrap;
width: 100%;
}
.tabs-content {
display: inline-flex;
padding: 0 30rpx;
}
.tab-item {
padding: 24rpx 30rpx;
font-size: 30rpx;
color: #999999;
position: relative;
transition: all 0.3s;
font-weight: 500;
}
.tab-item.active {
color: #e60012;
font-weight: bold;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80rpx;
height: 4rpx;
background-color: #e60012;
border-radius: 2rpx;
}
}
.avatar-scroll {
flex: 1;
overflow: hidden;
box-sizing: border-box;
}
.grid-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30rpx;
padding: 30rpx;
}
.grid-item {
aspect-ratio: 1; /* Make it square */
border-radius: 32rpx;
overflow: hidden;
position: relative;
background: #f5f5f5;
}
.avatar-img {
width: 100%;
height: 100%;
}
.action-overlay {
position: absolute;
bottom: 20rpx;
right: 20rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(255, 255, 255, 0.2);
padding: 0;
margin: 0;
line-height: 1;
&::after {
border: none;
}
}
.action-btn.share .icon {
font-size: 30rpx;
}
.action-btn .icon {
color: #fff;
font-size: 36rpx;
font-weight: normal;
}
.loading-state,
.empty-state,
.no-more {
text-align: center;
padding: 40rpx;
color: #999999;
font-size: 24rpx;
}
.bottom-spacer {
height: 140rpx; /* Space for fixed bottom bar */
}
.bottom-action-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 20rpx 30rpx calc(20rpx + constant(safe-area-inset-bottom));
padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 200;
}
.create-btn {
background: linear-gradient(135deg, #ff3b30, #ff1744);
color: #fff;
border-radius: 50rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&:active {
opacity: 0.9;
}
}
</style>