Files
2026-03-04 12:35:59 +08:00

542 lines
12 KiB
Vue
Raw Permalink 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"
/>
<RewardAd ref="rewardAdRef" @onReward="handleAdReward" />
</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, watchAdReward } from "@/api/system.js";
import { checkAbilityAndHandle } from "@/utils/ability.js";
import { useUserStore } from "@/stores/user";
import NavBar from "@/components/NavBar/NavBar.vue";
import RewardAd from "@/components/RewardAd/RewardAd.vue";
const userStore = useUserStore();
const loginPopupRef = ref(null);
const rewardAdRef = 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`,
});
});
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 handleAdReward = async (token) => {
try {
const res = await watchAdReward(token);
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;
}
const canProceed = await checkAbilityAndHandle(
"avatar_download",
rewardAdRef,
);
if (!canProceed) 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>