484 lines
12 KiB
Vue
484 lines
12 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||
import { onShow, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||
import Container from "@/components/Container.vue";
|
||
import PointRecord from "@/components/PointRecord.vue";
|
||
import RingBarChart from "@/components/RingBarChart.vue";
|
||
import SModal from "@/components/SModal.vue";
|
||
import Signin from "@/components/Signin.vue";
|
||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||
import RewardUs from "@/components/RewardUs.vue";
|
||
|
||
import {
|
||
getHomeData,
|
||
getPointBookConfigAPI,
|
||
getPointBookListAPI,
|
||
getPointBookStatisticsAPI,
|
||
} from "@/apis";
|
||
|
||
import { getElementRect } from "@/util";
|
||
|
||
import { generateKDEHeatmapImage } from "@/kde-heatmap";
|
||
|
||
import useStore from "@/store";
|
||
import { storeToRefs } from "pinia";
|
||
const store = useStore();
|
||
const { updateUser } = store;
|
||
const { user } = storeToRefs(store);
|
||
|
||
const isIOS = computed(() => {
|
||
const systemInfo = uni.getDeviceInfo();
|
||
return systemInfo.osName === "ios";
|
||
});
|
||
|
||
const loadImage = ref(true);
|
||
const showModal = ref(false);
|
||
const showTip = ref(false);
|
||
const data = ref({
|
||
weeksCheckIn: [],
|
||
});
|
||
|
||
const list = ref([]);
|
||
const bowTargetSrc = ref("");
|
||
const heatMapImageSrc = ref(""); // 存储热力图图片地址
|
||
const canvasVisible = ref(false); // 控制canvas显示状态
|
||
|
||
const toListPage = () => {
|
||
uni.navigateTo({
|
||
url: "/pages/point-book-list",
|
||
});
|
||
};
|
||
|
||
const onSignin = () => {
|
||
showModal.value = true;
|
||
};
|
||
|
||
const startScoring = () => {
|
||
if (user.value.id) {
|
||
uni.navigateTo({
|
||
url: "/pages/point-book-create",
|
||
});
|
||
} else {
|
||
showModal.value = true;
|
||
}
|
||
};
|
||
|
||
const loadData = async () => {
|
||
const result = await getPointBookListAPI(1);
|
||
list.value = result.slice(0, 3);
|
||
const result2 = await getPointBookStatisticsAPI();
|
||
data.value = result2;
|
||
|
||
const rect = await getElementRect(".heat-map");
|
||
let hot = 0;
|
||
if (result2.checkInCount > -3 && result2.checkInCount < 3) hot = 1;
|
||
else if (result2.checkInCount >= 3) hot = 2;
|
||
else if (result2.checkInCount >= 5) hot = 3;
|
||
else if (result2.checkInCount === 7) hot = 4;
|
||
uni.$emit("update-hot", hot);
|
||
// 异步生成热力图,不阻塞UI
|
||
const generateHeatmapAsync = async () => {
|
||
const weekArrows = result2.weekArrows
|
||
.filter((item) => item.x && item.y)
|
||
.map((item) => [item.x, item.y]);
|
||
|
||
try {
|
||
// 渐进式渲染:数据量大时先快速渲染粗略版本
|
||
if (weekArrows.length > 1000) {
|
||
const quickPath = await generateKDEHeatmapImage(
|
||
"heatMapCanvas",
|
||
rect.width,
|
||
rect.height,
|
||
weekArrows,
|
||
{
|
||
range: [0, 1],
|
||
gridSize: 80, // 先使用较小的gridSize快速显示
|
||
bandwidth: 0.2,
|
||
showPoints: false,
|
||
}
|
||
);
|
||
heatMapImageSrc.value = quickPath;
|
||
// 延迟后再渲染精细版本
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
}
|
||
|
||
// 渲染最终精细版本
|
||
const finalPath = await generateKDEHeatmapImage(
|
||
"heatMapCanvas",
|
||
rect.width,
|
||
rect.height,
|
||
weekArrows,
|
||
{
|
||
range: [0, 1],
|
||
gridSize: 120, // 更高的网格密度,减少锯齿
|
||
bandwidth: 0.15, // 稍小的带宽,让热力图更细腻
|
||
showPoints: false,
|
||
}
|
||
);
|
||
heatMapImageSrc.value = finalPath;
|
||
loadImage.value = false;
|
||
console.log("热力图图片地址:", finalPath);
|
||
} catch (error) {
|
||
console.error("生成热力图图片失败:", error);
|
||
loadImage.value = false;
|
||
}
|
||
};
|
||
|
||
// 异步生成热力图,不阻塞UI
|
||
generateHeatmapAsync();
|
||
};
|
||
|
||
watch(
|
||
() => user.value.id,
|
||
(id) => {
|
||
if (id) loadData();
|
||
}
|
||
);
|
||
|
||
onShow(async () => {
|
||
if (user.value.id) loadData();
|
||
});
|
||
|
||
onMounted(async () => {
|
||
uni.$on("point-book-signin", onSignin);
|
||
const token = uni.getStorageSync(
|
||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||
);
|
||
if (!user.value.id && token) {
|
||
const data = await getHomeData();
|
||
if (data.user) updateUser(data.user);
|
||
}
|
||
const config = await getPointBookConfigAPI();
|
||
uni.setStorageSync("point-book-config", config);
|
||
if (config.targetOption && config.targetOption[0]) {
|
||
bowTargetSrc.value = config.targetOption[0].icon;
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
uni.$off("point-book-signin", onSignin);
|
||
});
|
||
|
||
onShareAppMessage(() => {
|
||
return {
|
||
title: "高效记录每一次射箭,深度分析助你提升!",
|
||
path: "pages/point-book-create",
|
||
imageUrl:
|
||
"https://static.shelingxingqiu.com/attachment/2025-09-22/dcz4m4nbgycqqwknrv.png",
|
||
};
|
||
});
|
||
onShareTimeline(() => {
|
||
return {
|
||
title: "高效记录每一次射箭,深度分析助你提升!",
|
||
query: "from=timeline",
|
||
imageUrl:
|
||
"https://static.shelingxingqiu.com/attachment/2025-09-22/dcz4m4nbgycqqwknrv.png",
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Container :bgType="4" bgColor="#F5F5F5" :whiteBackArrow="false" title="">
|
||
<view class="container">
|
||
<view class="daily-signin">
|
||
<view>
|
||
<image src="../static/week-check.png" />
|
||
</view>
|
||
<view :class="data.weeksCheckIn[0] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[0]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周一</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[1] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[1]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周二</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[2] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[2]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周三</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[3] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[3]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周四</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[4] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[4]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周五</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[5] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[5]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周六</text>
|
||
</view>
|
||
<view :class="data.weeksCheckIn[6] ? 'checked' : ''">
|
||
<image
|
||
v-if="data.weeksCheckIn[6]"
|
||
src="../static/checked-green2.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view v-else></view>
|
||
<text>周日</text>
|
||
</view>
|
||
</view>
|
||
<view class="statistics">
|
||
<view>
|
||
<text>{{ data.todayTotalArrow || "-" }}</text>
|
||
<text>今日射箭(箭)</text>
|
||
</view>
|
||
<view>
|
||
<text>{{ data.totalArrow || "-" }}</text>
|
||
<text>累计射箭(箭)</text>
|
||
</view>
|
||
<view>
|
||
<text>{{ data.totalDay || "-" }}</text>
|
||
<text>已训练天数(天)</text>
|
||
</view>
|
||
<view>
|
||
<text>{{ data.averageRing || "-" }}</text>
|
||
<text>平均环数(箭)</text>
|
||
</view>
|
||
<view>
|
||
<text>{{
|
||
data.yellowRate !== undefined
|
||
? Number(data.yellowRate * 100).toFixed(2)
|
||
: "-"
|
||
}}</text>
|
||
<text>黄心率(%)</text>
|
||
</view>
|
||
<view>
|
||
<button hover-class="none" @click="startScoring">
|
||
<image src="../static/start-scoring.png" mode="widthFix" />
|
||
</button>
|
||
</view>
|
||
</view>
|
||
<view class="title">
|
||
<image src="../static/point-book-title1.png" mode="widthFix" />
|
||
</view>
|
||
<view class="heat-map">
|
||
<image
|
||
:src="bowTargetSrc || '../static/bow-target.png'"
|
||
mode="widthFix"
|
||
/>
|
||
<image v-if="heatMapImageSrc" :src="heatMapImageSrc" mode="widthFix" />
|
||
<view v-if="loadImage" class="load-image">
|
||
<text>生成中...</text>
|
||
</view>
|
||
<canvas
|
||
canvas-id="heatMapCanvas"
|
||
style="
|
||
width: 100%;
|
||
height: 100%;
|
||
position: absolute;
|
||
top: -1000px;
|
||
left: 0;
|
||
z-index: 2;
|
||
"
|
||
/>
|
||
</view>
|
||
<view class="reward" v-if="data.totalArrow">
|
||
<button hover-class="none" @click="showTip = true">
|
||
<image src="../static/reward-us.png" mode="widthFix" />
|
||
</button>
|
||
</view>
|
||
<RingBarChart :data="data.ringRate" />
|
||
<view class="title" v-if="user.id">
|
||
<image src="../static/point-book-title2.png" mode="widthFix" />
|
||
</view>
|
||
<block v-for="(item, index) in list" :key="index">
|
||
<PointRecord :data="item" />
|
||
</block>
|
||
<view
|
||
class="see-more"
|
||
@click="toListPage"
|
||
v-if="list.length"
|
||
:style="{ marginBottom: isIOS ? '10rpx' : 0 }"
|
||
>
|
||
<text>查看所有记录</text>
|
||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||
</view>
|
||
</view>
|
||
<SModal :show="showModal" :onClose="() => (showModal = false)" :noBg="true">
|
||
<Signin :onClose="() => (showModal = false)" :noBg="true" />
|
||
</SModal>
|
||
<ScreenHint2 :show="showTip" :onClose="() => (showTip = false)">
|
||
<RewardUs :show="showTip" :onClose="() => (showTip = false)" />
|
||
</ScreenHint2>
|
||
</Container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.container {
|
||
width: calc(100% - 50rpx);
|
||
padding: 25rpx;
|
||
}
|
||
.statistics {
|
||
border-radius: 25rpx;
|
||
border-bottom-left-radius: 50rpx;
|
||
border-bottom-right-radius: 50rpx;
|
||
border: 4rpx solid #fed848;
|
||
background: #fff;
|
||
font-size: 22rpx;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
padding: 25rpx 0;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
.statistics > view {
|
||
width: 33.33%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.statistics > view:nth-child(-n + 3) {
|
||
margin-bottom: 25rpx;
|
||
}
|
||
.statistics > view:nth-child(2),
|
||
.statistics > view:nth-child(5) {
|
||
border-left: 1rpx solid #eeeeee;
|
||
border-right: 1rpx solid #eeeeee;
|
||
box-sizing: border-box;
|
||
}
|
||
.statistics > view > text {
|
||
text-align: center;
|
||
font-size: 22rpx;
|
||
color: #333333;
|
||
}
|
||
.statistics > view > text:first-child {
|
||
font-weight: 500;
|
||
font-size: 40rpx;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
.statistics > view:last-child > button > image {
|
||
width: 164rpx;
|
||
}
|
||
.daily-signin {
|
||
display: grid;
|
||
grid-template-columns: repeat(8, 1fr);
|
||
gap: 10rpx;
|
||
border-radius: 20rpx;
|
||
margin-bottom: 25rpx;
|
||
}
|
||
.daily-signin > view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.daily-signin > view:not(:first-child) {
|
||
background: #f8f8f8;
|
||
box-sizing: border-box;
|
||
width: 78rpx;
|
||
height: 94rpx;
|
||
padding-top: 10rpx;
|
||
}
|
||
.daily-signin > view:not(:first-child) > image {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
}
|
||
.daily-signin > view:not(:first-child) > view {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
border-radius: 50%;
|
||
box-sizing: border-box;
|
||
border: 2rpx solid #333;
|
||
}
|
||
.daily-signin > view > text {
|
||
font-size: 20rpx;
|
||
color: #999999;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
margin-top: 10rpx;
|
||
}
|
||
.daily-signin > view:first-child > image {
|
||
width: 72rpx;
|
||
height: 94rpx;
|
||
}
|
||
.checked {
|
||
border: 2rpx solid #000;
|
||
}
|
||
.checked > text {
|
||
color: #333 !important;
|
||
}
|
||
.title {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
margin: 25rpx 0;
|
||
}
|
||
.title > image {
|
||
width: 566rpx;
|
||
}
|
||
.heat-map {
|
||
position: relative;
|
||
margin: 10rpx;
|
||
width: calc(100vw - 70rpx);
|
||
height: calc(100vw - 70rpx);
|
||
}
|
||
.heat-map > image {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
}
|
||
.load-image {
|
||
position: absolute;
|
||
width: 160rpx;
|
||
top: calc(50% - 65rpx);
|
||
left: calc(50% - 75rpx);
|
||
/* background: rgb(0 0 0 / 0.4); */
|
||
/* padding: 20rpx; */
|
||
color: #525252;
|
||
font-size: 20rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.reward {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: -100rpx;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
.reward > button {
|
||
width: 100rpx;
|
||
}
|
||
.reward > button > image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
</style>
|