Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9699f1356f | ||
|
|
899a222e3f | ||
|
|
ca727ec1f7 | ||
|
|
aa33a931b1 | ||
|
|
bac84536c7 | ||
|
|
f044a95872 | ||
|
|
94c46bb7df | ||
|
|
318b952a9b | ||
|
|
05f73acf47 | ||
|
|
14437bb5a7 | ||
|
|
eca15178f4 | ||
|
|
f16b486a89 | ||
|
|
6c69e14630 | ||
|
|
9eb6ef7cf1 | ||
|
|
34118d59d3 | ||
|
|
00f84fc3e4 | ||
|
|
b82474913a | ||
|
|
5bb9df8a03 | ||
|
|
9d9f005ca3 | ||
|
|
bad336c7d4 | ||
|
|
605a0b2d91 | ||
|
|
cee09cd7fa | ||
|
|
c9bfa6792b | ||
|
|
ae3ba60191 | ||
|
|
b0ddf147aa | ||
|
|
f4b37fba00 | ||
|
|
f8bf9fdb63 |
@@ -4,6 +4,10 @@
|
|||||||
"libVersion": "3.7.7",
|
"libVersion": "3.7.7",
|
||||||
"packOptions": {
|
"packOptions": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "static/coin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"value": "static/common/dialog-light.png"
|
"value": "static/common/dialog-light.png"
|
||||||
|
|||||||
@@ -599,14 +599,13 @@ export const getDeviceWifiStatusAPI = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
|
||||||
export const getHardwareBoxVersionAPI = async () => {
|
|
||||||
return request("GET", "/user/hardwareBox/version");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||||
export const sendHardwareBoxUpdateAPI = async (data) => {
|
export const sendHardwareBoxUpdateAPI = async ({versionNumber, wifiSsid, wifiPassword}) => {
|
||||||
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
return request("POST", "/user/hardwareBox/sendUpdate", {
|
||||||
|
versionNumber,
|
||||||
|
wifiSsid,
|
||||||
|
wifiPassword,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addNoteAPI = async (id, remark) => {
|
export const addNoteAPI = async (id, remark) => {
|
||||||
@@ -771,6 +770,21 @@ export const getGiftDetailAPI = (id) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 分页获取启用门店及当前用户在各店的会员状态。
|
||||||
|
export const getStoreVipListAPI = ({page = 1, pageSize = 20} = {}) => {
|
||||||
|
return request("GET", "/store/list", {page, pageSize});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查询当前用户在指定门店的会员状态及权益剩余次数。
|
||||||
|
export const getStoreBenefitAPI = (storeId) => {
|
||||||
|
return request("GET", "/store/benefit", {storeId});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取体验中心 VIP 介绍纯文本,无需登录。
|
||||||
|
export const getStoreVipIntroAPI = () => {
|
||||||
|
return request("GET", "/store/vip-intro");
|
||||||
|
};
|
||||||
|
|
||||||
// 根据用户定位分页获取附近门店。
|
// 根据用户定位分页获取附近门店。
|
||||||
export const getNearbyStoresAPI = ({
|
export const getNearbyStoresAPI = ({
|
||||||
longitude,
|
longitude,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
|||||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||||
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
|
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
|
||||||
|
|
||||||
|
// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。
|
||||||
|
export const getTrainingEndAudioKey = (completed) =>
|
||||||
|
completed === true ? "通关成功" : "通关失败";
|
||||||
|
|
||||||
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
||||||
if (!shootData || typeof shootData !== "object") return [];
|
if (!shootData || typeof shootData !== "object") return [];
|
||||||
const ring = Number(shootData.ring);
|
const ring = Number(shootData.ring);
|
||||||
@@ -201,6 +205,10 @@ export const audioFils = {
|
|||||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
|
||||||
练习结束:
|
练习结束:
|
||||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
|
||||||
|
通关成功:
|
||||||
|
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3",
|
||||||
|
通关失败:
|
||||||
|
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3",
|
||||||
射箭声音:
|
射箭声音:
|
||||||
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
||||||
命中:
|
命中:
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ const tabs = [
|
|||||||
|
|
||||||
function handleTabClick(index) {
|
function handleTabClick(index) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
uni.navigateTo({
|
uni.showToast({
|
||||||
url: "/pages/member/be-vip",
|
title: "功能尚未开启",
|
||||||
|
icon: "none",
|
||||||
});
|
});
|
||||||
|
// uni.navigateTo({
|
||||||
|
// url: "/pages/member/be-vip",
|
||||||
|
// });
|
||||||
}
|
}
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ const phaseTextMap = {
|
|||||||
installing: "正在安装固件",
|
installing: "正在安装固件",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 设备详情中的 latestVersion 是升级目标,version 是当前固件版本。
|
||||||
|
export const getOtaInfoFromDetail = (detail) => ({
|
||||||
|
versionNumber: String(detail?.latestVersion ?? "").trim(),
|
||||||
|
currentVersion: String(detail?.version ?? "").trim(),
|
||||||
|
versionInfo: String(detail?.versionInfo ?? "").trim(),
|
||||||
|
needUpdate: detail?.needUpdate === true || Number(detail?.needUpdate) === 1,
|
||||||
|
forceUpdate: detail?.forceUpdate === true || Number(detail?.forceUpdate) === 1,
|
||||||
|
});
|
||||||
|
|
||||||
// OTA 状态由首页、WiFi 设置页和我的设备页共享,页面切换后仍可继续接收进度。
|
// OTA 状态由首页、WiFi 设置页和我的设备页共享,页面切换后仍可继续接收进度。
|
||||||
const updating = ref(false);
|
const updating = ref(false);
|
||||||
const progress = ref(0);
|
const progress = ref(0);
|
||||||
@@ -115,6 +124,7 @@ function handleSocketMessage(message) {
|
|||||||
} else if (!isCurrentVersionMessage(message.data)) {
|
} else if (!isCurrentVersionMessage(message.data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||||
|
|
||||||
const nextProgress = Math.min(
|
const nextProgress = Math.min(
|
||||||
100,
|
100,
|
||||||
@@ -138,6 +148,7 @@ function handleSocketMessage(message) {
|
|||||||
} else if (!isCurrentVersionMessage(message.data)) {
|
} else if (!isCurrentVersionMessage(message.data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||||
|
|
||||||
if (message.data?.status === "success") {
|
if (message.data?.status === "success") {
|
||||||
finishUpdate("success");
|
finishUpdate("success");
|
||||||
@@ -160,16 +171,20 @@ export const useOtaUpdate = () => {
|
|||||||
|
|
||||||
const startUpdate = async ({
|
const startUpdate = async ({
|
||||||
versionNumber,
|
versionNumber,
|
||||||
resourceUrl,
|
|
||||||
wifiSsid = "",
|
wifiSsid = "",
|
||||||
wifiPassword = "",
|
wifiPassword = "",
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
if (updating.value) return false;
|
if (updating.value) return false;
|
||||||
|
const nextVersion = String(versionNumber || "").trim();
|
||||||
|
if (!nextVersion) {
|
||||||
|
uni.showToast({ title: "缺少固件目标版本", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
updateRunId += 1;
|
updateRunId += 1;
|
||||||
const runId = updateRunId;
|
const runId = updateRunId;
|
||||||
targetVersion = String(versionNumber || "");
|
targetVersion = nextVersion;
|
||||||
lastFinishedVersion = "";
|
lastFinishedVersion = "";
|
||||||
successCallback = onSuccess || null;
|
successCallback = onSuccess || null;
|
||||||
resultVisible.value = false;
|
resultVisible.value = false;
|
||||||
@@ -188,7 +203,6 @@ export const useOtaUpdate = () => {
|
|||||||
versionNumber: targetVersion,
|
versionNumber: targetVersion,
|
||||||
wifiSsid,
|
wifiSsid,
|
||||||
wifiPassword,
|
wifiPassword,
|
||||||
resourceUrl,
|
|
||||||
});
|
});
|
||||||
if (runId !== updateRunId || !updating.value) return false;
|
if (runId !== updateRunId || !updating.value) return false;
|
||||||
if (!updateResult?.taskId) {
|
if (!updateResult?.taskId) {
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
"appid" : "wxa8f5989dcd45cc23",
|
"appid" : "wxa8f5989dcd45cc23",
|
||||||
"packOptions" : {
|
"packOptions" : {
|
||||||
"ignore" : [
|
"ignore" : [
|
||||||
|
{
|
||||||
|
"type" : "folder",
|
||||||
|
"value" : "static/coin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type" : "file",
|
"type" : "file",
|
||||||
"value" : "static/common/dialog-light.png"
|
"value" : "static/common/dialog-light.png"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getPrecisionShotAudioKeys,
|
getPrecisionShotAudioKeys,
|
||||||
getRhythmShotAudioKeys,
|
getRhythmShotAudioKeys,
|
||||||
getStabilityShotAudioKeys,
|
getStabilityShotAudioKeys,
|
||||||
|
getTrainingEndAudioKey,
|
||||||
getTrainingStartAudioKey,
|
getTrainingStartAudioKey,
|
||||||
STABILITY_ENERGY_50_AUDIO_KEY,
|
STABILITY_ENERGY_50_AUDIO_KEY,
|
||||||
STABILITY_ENERGY_70_AUDIO_KEY,
|
STABILITY_ENERGY_70_AUDIO_KEY,
|
||||||
@@ -603,6 +604,10 @@ function getAckAudioKeys(message, businessMessage) {
|
|||||||
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
|
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
|
||||||
return ["比赛结束"];
|
return ["比赛结束"];
|
||||||
case ServerMessageType.SERVER_MSG_PRACTICE_END:
|
case ServerMessageType.SERVER_MSG_PRACTICE_END:
|
||||||
|
// 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。
|
||||||
|
if (currentContext?.practiceEndAudioKey === "练习结束") {
|
||||||
|
return [getTrainingEndAudioKey(businessMessage?.completed)];
|
||||||
|
}
|
||||||
return [currentContext?.practiceEndAudioKey || "比赛结束"];
|
return [currentContext?.practiceEndAudioKey || "比赛结束"];
|
||||||
case ServerMessageType.SERVER_MSG_CHECK:
|
case ServerMessageType.SERVER_MSG_CHECK:
|
||||||
return getTestDistanceAudioKeys(businessMessage?.shootData);
|
return getTestDistanceAudioKeys(businessMessage?.shootData);
|
||||||
|
|||||||
@@ -121,6 +121,9 @@
|
|||||||
{
|
{
|
||||||
"root": "pages/coin",
|
"root": "pages/coin",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "vip-intro"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "index"
|
"path": "index"
|
||||||
},
|
},
|
||||||
@@ -156,6 +159,9 @@
|
|||||||
{
|
{
|
||||||
"path": "device-bind-failure"
|
"path": "device-bind-failure"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "device-already-bound"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "device-intro"
|
"path": "device-intro"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,13 +38,12 @@ defineProps({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 706rpx;
|
width: 100%;
|
||||||
height: 60rpx;
|
height: 60rpx;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border: 2rpx solid rgba(255, 217, 71, 0.25);
|
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: rgba(255, 217, 71, 0.06);
|
background: linear-gradient(90deg, transparent, rgba(4, 4, 4, 0.7), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-item {
|
.balance-item {
|
||||||
@@ -69,7 +68,7 @@ defineProps({
|
|||||||
|
|
||||||
.balance-list__line {
|
.balance-list__line {
|
||||||
width: 2rpx;
|
width: 2rpx;
|
||||||
height: 28rpx;
|
height: 16rpx;
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
margin: 0 22rpx;
|
margin: 0 22rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
switchable: { type: Boolean, default: false },
|
||||||
onBack: {
|
onBack: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const emit = defineEmits(["select-store"]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,16 +32,20 @@ const props = defineProps({
|
|||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</text>
|
</text>
|
||||||
<text v-if="subtitle" class="coin-header__subtitle">
|
<view v-if="subtitle" class="coin-header__store" @click="switchable && emit('select-store')">
|
||||||
{{ subtitle }}
|
<text class="coin-header__subtitle">{{ subtitle }}</text>
|
||||||
</text>
|
<image v-if="switchable" class="coin-header__switch" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-switch.png" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</Header>
|
</Header>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
|
.coin-header__store { display: flex; align-items: center; max-width: 430rpx; }
|
||||||
|
.coin-header__store .coin-header__subtitle { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.coin-header__switch { flex-shrink: 0; width: 28rpx; height: 28rpx; margin-left: 10rpx; }
|
||||||
.coin-header {
|
.coin-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_IMAGE =
|
const DEFAULT_PRODUCT_IMAGE =
|
||||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-item.png";
|
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
images: {
|
images: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const productImage = computed(() => {
|
|||||||
height: 312rpx;
|
height: 312rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
|
background-image: url("https://static.shelingxingqiu.com/shootmini/static/coin/product-card-bg.png");
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-card__image {
|
.product-card__image {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
benefit: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 与会员卡共用剩余次数,接口缺失时显示占位。 -->
|
||||||
|
<view class="benefit-card">
|
||||||
|
<text class="benefit-card__badge">{{ benefit.remaining }}次</text>
|
||||||
|
<image class="benefit-card__icon" :src="benefit.icon" mode="aspectFit" />
|
||||||
|
<view class="benefit-card__content">
|
||||||
|
<text class="benefit-card__title">{{ benefit.title }}</text>
|
||||||
|
<text class="benefit-card__description">{{ benefit.description }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.benefit-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 176rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding: 28rpx 28rpx 28rpx 56rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(180deg, #080808, rgba(152, 124, 84, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// .benefit-card:last-child {
|
||||||
|
// margin-bottom: 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.benefit-card__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 16rpx 0 16rpx 0;
|
||||||
|
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
margin-right: 38rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card__title {
|
||||||
|
display: block;
|
||||||
|
color: #fae6bc;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 42rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card__description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tabs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: String,
|
||||||
|
default: "vip",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 业务 Tab 只切换内容,不接管系统导航。 -->
|
||||||
|
<view class="section-tabs">
|
||||||
|
<view
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="section-tabs__item"
|
||||||
|
:class="{ 'section-tabs__item--active': active === tab.key }"
|
||||||
|
hover-class="section-tabs__item--pressed"
|
||||||
|
@click="emit('select', tab.key)"
|
||||||
|
>
|
||||||
|
<text>{{ tab.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.section-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 40rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 326rpx;
|
||||||
|
height: 68rpx;
|
||||||
|
border-radius: 34rpx;
|
||||||
|
color: #999999;
|
||||||
|
background: #000000;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs__item--active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs__item--pressed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
membership: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
benefits: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["introduction"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="vip-card" :class="{ 'vip-card--active': membership.active }">
|
||||||
|
<!-- 切图仅含静态字效与徽章,次数、有效期和状态独立渲染。 -->
|
||||||
|
<image class="vip-card__background" :src="membership.cardImage" mode="aspectFit" />
|
||||||
|
<view class="vip-card__benefits">
|
||||||
|
<view v-for="benefit in benefits" :key="benefit.key" class="vip-card__benefit">
|
||||||
|
<text class="vip-card__label">{{ benefit.title }}</text>
|
||||||
|
<view class="vip-card__count">
|
||||||
|
<text class="vip-card__number">{{ membership.remaining[benefit.key] }}</text>
|
||||||
|
<text class="vip-card__unit">次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="vip-card__status">
|
||||||
|
{{ membership.active ? `有效期至${membership.expiresAt}` : "未开通" }}
|
||||||
|
</text>
|
||||||
|
<view class="vip-card__introduction" @click="emit('introduction')">
|
||||||
|
<text>查看更多关于体验中心VIP的介绍 ></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vip-card {
|
||||||
|
position: relative;
|
||||||
|
width: 750rpx;
|
||||||
|
height: 288rpx;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 750rpx;
|
||||||
|
height: 288rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__benefits {
|
||||||
|
position: absolute;
|
||||||
|
top: 124rpx;
|
||||||
|
left: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 400rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__benefit {
|
||||||
|
width: 120rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__count {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
height: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__count::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 16rpx;
|
||||||
|
bottom: 4rpx;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #ffd947;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__number {
|
||||||
|
position: relative;
|
||||||
|
font-size: 40rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__unit {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 2rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__status {
|
||||||
|
position: absolute;
|
||||||
|
top: 212rpx;
|
||||||
|
left: 496rpx;
|
||||||
|
width: 232rpx;
|
||||||
|
color: #888888;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card--active .vip-card__status {
|
||||||
|
color: #b47d42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-card__introduction {
|
||||||
|
position: absolute;
|
||||||
|
left: 74rpx;
|
||||||
|
bottom: 16rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["open"]);
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (!props.active) emit("open");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 当前门店已开通时仅展示状态,未开通时提供线下开通提醒。 -->
|
||||||
|
<view class="vip-footer">
|
||||||
|
<text class="vip-footer__price">特惠价格:9.9元/90天</text>
|
||||||
|
<view class="vip-footer__status" @click="handleOpen">
|
||||||
|
<image class="vip-footer__badge" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-badge.png" mode="aspectFit" />
|
||||||
|
<text>{{ active ? "您已开通VIP" : "开通方式:请前往线下门店联系工作人员开通" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vip-footer {
|
||||||
|
margin-top: 84rpx;
|
||||||
|
padding: 0 24rpx 22rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-footer__price {
|
||||||
|
display: block;
|
||||||
|
color: #fae6bc;
|
||||||
|
font-size: 40rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-footer__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 90rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
padding: 8rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 46rpx;
|
||||||
|
color: #333333;
|
||||||
|
background: #ffd947;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-footer__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 74rpx;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 金币快捷入口保持原有路由与图标配置。
|
||||||
export const quickMenus = [
|
export const quickMenus = [
|
||||||
{
|
{
|
||||||
key: "rules",
|
key: "rules",
|
||||||
@@ -20,3 +21,30 @@ export const quickMenus = [
|
|||||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 体验中心业务 Tab。
|
||||||
|
export const storeSections = [
|
||||||
|
{ key: "vip", label: "体验中心VIP" },
|
||||||
|
{ key: "coin", label: "我的金币" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storeVipBenefits = [
|
||||||
|
{
|
||||||
|
key: "trial",
|
||||||
|
title: "免费体验权益",
|
||||||
|
description: "使用该权益可免费体验智能弓箭30分钟1次,加入官方社群还可以额外获赠1次。",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-trial.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "discount",
|
||||||
|
title: "特惠购买权益",
|
||||||
|
description: "使用该权益可以19.9元的特惠价格购买30分钟体验服务1次。",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-discount.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "drink",
|
||||||
|
title: "到店赠饮权益",
|
||||||
|
description: "使用该权益每次到店后可获得免费赠送的指定饮品一支。",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-drink.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
||||||
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
||||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||||
import RewardProductCard from "./components/RewardProductCard.vue";
|
import RewardProductCard from "./components/RewardProductCard.vue";
|
||||||
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
||||||
import { quickMenus } from "./data";
|
import { quickMenus, storeSections, storeVipBenefits } from "./data";
|
||||||
|
import StoreSectionTabs from "./components/StoreSectionTabs.vue";
|
||||||
|
import StoreVipCard from "./components/StoreVipCard.vue";
|
||||||
|
import StoreBenefitCard from "./components/StoreBenefitCard.vue";
|
||||||
|
import StoreVipFooter from "./components/StoreVipFooter.vue";
|
||||||
|
import { getSelectedExperienceStore, rememberExperienceStore, loadStoreMembership } from "@/utils/storeVip";
|
||||||
|
|
||||||
|
const activeTab = ref("vip");
|
||||||
|
const membership = ref(null);
|
||||||
|
const vipLoading = ref(false);
|
||||||
|
const vipError = ref("");
|
||||||
|
const displayedBenefits = computed(() => storeVipBenefits.map((benefit) => ({
|
||||||
|
...benefit, remaining: membership.value?.remaining[benefit.key] ?? "--",
|
||||||
|
})));
|
||||||
|
const loadMembership = async () => {
|
||||||
|
if (!selectedStoreId.value || vipLoading.value) return;
|
||||||
|
vipLoading.value = true;
|
||||||
|
vipError.value = "";
|
||||||
|
membership.value = null;
|
||||||
|
try {
|
||||||
|
const result = await loadStoreMembership(selectedStoreId.value);
|
||||||
|
membership.value = result;
|
||||||
|
if (result.name) selectedStoreName.value = result.name;
|
||||||
|
rememberExperienceStore(selectedStoreId.value, selectedStoreName.value);
|
||||||
|
} catch (error) {
|
||||||
|
vipError.value = "会员信息加载失败,请点击重试或切换门店";
|
||||||
|
console.error("加载门店会员失败", error);
|
||||||
|
} finally {
|
||||||
|
vipLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectStore = () => uni.redirectTo({ url: `/pages/coin/nearby-stores?tab=${activeTab.value}` });
|
||||||
|
const selectTab = (tab) => {
|
||||||
|
if (storeSections.some((item) => item.key === tab)) activeTab.value = tab;
|
||||||
|
};
|
||||||
|
const showVipIntroduction = () => uni.navigateTo({ url: "/pages/coin/vip-intro" });
|
||||||
|
const vipOpeningVisible = ref(false);
|
||||||
|
const showVipOpening = () => { vipOpeningVisible.value = true; };
|
||||||
|
const closeVipOpening = () => { vipOpeningVisible.value = false; };
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const coinSummary = ref({
|
const coinSummary = ref({
|
||||||
@@ -19,8 +58,8 @@ const selectedStoreId = ref("");
|
|||||||
const selectedStoreName = ref("");
|
const selectedStoreName = ref("");
|
||||||
const rewardProducts = ref([]);
|
const rewardProducts = ref([]);
|
||||||
const productPage = ref(0);
|
const productPage = ref(0);
|
||||||
const productTotal = ref(0);
|
|
||||||
const productLoading = ref(false);
|
const productLoading = ref(false);
|
||||||
|
const productError = ref(false);
|
||||||
const productNoMore = ref(false);
|
const productNoMore = ref(false);
|
||||||
|
|
||||||
const loadCoinSummary = async () => {
|
const loadCoinSummary = async () => {
|
||||||
@@ -40,6 +79,7 @@ const loadProducts = async ({ reset = false } = {}) => {
|
|||||||
|
|
||||||
const nextPage = reset ? 1 : productPage.value + 1;
|
const nextPage = reset ? 1 : productPage.value + 1;
|
||||||
productLoading.value = true;
|
productLoading.value = true;
|
||||||
|
productError.value = false;
|
||||||
if (reset) productNoMore.value = false;
|
if (reset) productNoMore.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -65,7 +105,6 @@ const loadProducts = async ({ reset = false } = {}) => {
|
|||||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
const responsePageSize =
|
const responsePageSize =
|
||||||
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||||
productTotal.value = Number.isFinite(totalCount) ? totalCount : 0;
|
|
||||||
|
|
||||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
productNoMore.value = productPage.value >= pageCount;
|
productNoMore.value = productPage.value >= pageCount;
|
||||||
@@ -79,6 +118,7 @@ const loadProducts = async ({ reset = false } = {}) => {
|
|||||||
rewardProducts.value = [];
|
rewardProducts.value = [];
|
||||||
productPage.value = 0;
|
productPage.value = 0;
|
||||||
}
|
}
|
||||||
|
productError.value = true;
|
||||||
console.error("加载礼品列表失败", error);
|
console.error("加载礼品列表失败", error);
|
||||||
} finally {
|
} finally {
|
||||||
productLoading.value = false;
|
productLoading.value = false;
|
||||||
@@ -115,7 +155,7 @@ const decodeRouteText = (value = "") => {
|
|||||||
const onMenuSelect = (menu) => {
|
const onMenuSelect = (menu) => {
|
||||||
if (menu.key === "nearbyStores") {
|
if (menu.key === "nearbyStores") {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: "/pages/coin/nearby-stores",
|
url: `/pages/coin/nearby-stores?tab=${activeTab.value}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,27 +183,69 @@ const toProductDetail = (product) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onLoad((options = {}) => {
|
onLoad((options = {}) => {
|
||||||
const storeId = String(options.storeId || "");
|
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||||
|
const previous = getSelectedExperienceStore();
|
||||||
|
const storeId = String(options.storeId || previous?.id || "");
|
||||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||||
uni.redirectTo({
|
selectStore();
|
||||||
url: "/pages/coin/nearby-stores",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedStoreId.value = storeId;
|
selectedStoreId.value = storeId;
|
||||||
selectedStoreName.value = decodeRouteText(options.storeName);
|
selectedStoreName.value = options.storeId
|
||||||
loadCoinSummary();
|
? decodeRouteText(options.storeName)
|
||||||
|
: previous?.name || "";
|
||||||
|
rememberExperienceStore(storeId, selectedStoreName.value);
|
||||||
loadProducts({ reset: true });
|
loadProducts({ reset: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 从介绍、商品等页面返回时刷新会员和余额。
|
||||||
|
onShow(() => {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
loadMembership();
|
||||||
|
loadCoinSummary();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
|
<!-- 标题固定,门店切换复用原有附近门店页面。 -->
|
||||||
|
<Container :bgType="6" bgColor="#22222e" :isHome="true" @scrolltolower="activeTab === 'coin' && loadProducts()">
|
||||||
<template #header>
|
<template #header>
|
||||||
<CoinHeader title="我的金币" :subtitle="selectedStoreName" />
|
<CoinHeader title="线下体验中心" :subtitle="selectedStoreName || '选择门店'" switchable @select-store="selectStore" />
|
||||||
</template>
|
</template>
|
||||||
<view class="coin-page">
|
<view class="coin-page">
|
||||||
|
<StoreSectionTabs
|
||||||
|
:tabs="storeSections"
|
||||||
|
:active="activeTab"
|
||||||
|
@select="selectTab"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 卡片与权益列表共用当前门店的会员数据。 -->
|
||||||
|
<view v-if="activeTab === 'vip'" class="vip-section">
|
||||||
|
<view v-if="vipLoading" class="page-status">会员信息加载中...</view>
|
||||||
|
<view v-else-if="vipError" class="page-status" @click="loadMembership">{{ vipError }}</view>
|
||||||
|
<StoreVipCard
|
||||||
|
v-else-if="membership"
|
||||||
|
:membership="membership"
|
||||||
|
:benefits="storeVipBenefits"
|
||||||
|
@introduction="showVipIntroduction"
|
||||||
|
/>
|
||||||
|
<view class="vip-section__content">
|
||||||
|
<text class="vip-section__description">
|
||||||
|
门店体验中心VIP是为热爱射箭运动的用户专属打造的VIP服务,开通后即可享受以下三大专属权益:
|
||||||
|
</text>
|
||||||
|
<view class="vip-section__benefits">
|
||||||
|
<StoreBenefitCard
|
||||||
|
v-for="benefit in displayedBenefits"
|
||||||
|
:key="benefit.key"
|
||||||
|
:benefit="benefit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<StoreVipFooter v-if="membership" :active="membership.active" @open="showVipOpening" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保留原金币接口、分页与详情链路。 -->
|
||||||
|
<view v-else class="coin-section">
|
||||||
<CoinBalancePanel
|
<CoinBalancePanel
|
||||||
:cumulative="coinSummary.cumulative"
|
:cumulative="coinSummary.cumulative"
|
||||||
:available="coinSummary.available"
|
:available="coinSummary.available"
|
||||||
@@ -182,11 +264,23 @@ onLoad((options = {}) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="productLoading" class="page-status">加载中...</view>
|
||||||
|
<view v-else-if="productError" class="page-status" @click="loadProducts()">加载失败,点击重试</view>
|
||||||
|
<view v-else-if="productNoMore" class="page-status">{{ rewardProducts.length ? '没有更多商品了~' : '暂无兑换商品~' }}</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ModalDialog
|
||||||
|
:show="vipOpeningVisible"
|
||||||
|
content="请前往线下门店体验中心,联系工作人员开通VIP。"
|
||||||
|
:show-cancel="false"
|
||||||
|
confirm-text="我已知晓"
|
||||||
|
:on-confirm="closeVipOpening"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
|
.page-status { padding: 30rpx; color: #cccccc; text-align: center; font-size: 26rpx; }
|
||||||
.coin-page {
|
.coin-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
@@ -194,6 +288,30 @@ onLoad((options = {}) => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vip-section {
|
||||||
|
padding-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-section__content {
|
||||||
|
padding: 0 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-section__description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 50rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-section__benefits {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-section {
|
||||||
|
padding-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.reward-section {
|
.reward-section {
|
||||||
padding: 20rpx 28rpx 40rpx;
|
padding: 20rpx 28rpx 40rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import CoinHeader from "./components/CoinHeader.vue";
|
|||||||
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||||
import StoreCard from "./components/StoreCard.vue";
|
import StoreCard from "./components/StoreCard.vue";
|
||||||
import { getNearbyStoresAPI } from "@/apis";
|
import { getNearbyStoresAPI } from "@/apis";
|
||||||
|
import { rememberExperienceStore } from "@/utils/storeVip";
|
||||||
|
|
||||||
|
const activeTab = ref("vip");
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const DEFAULT_STORE_IMAGE =
|
const DEFAULT_STORE_IMAGE =
|
||||||
@@ -113,12 +116,16 @@ const selectStore = (store) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storeName = encodeURIComponent(store?.name || "");
|
const storeName = encodeURIComponent(store?.name || "");
|
||||||
|
rememberExperienceStore(storeId, store?.name || "");
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}`,
|
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}&tab=${activeTab.value}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onLoad(locateAndLoadStores);
|
onLoad((options = {}) => {
|
||||||
|
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||||
|
locateAndLoadStores();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import { getStoreVipIntroAPI } from "@/apis";
|
||||||
|
|
||||||
|
const content = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
const loadIntroduction = async () => {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
try {
|
||||||
|
const result = await getStoreVipIntroAPI();
|
||||||
|
if (typeof result?.content !== "string") {
|
||||||
|
throw new Error("体验中心 VIP 介绍响应格式异常");
|
||||||
|
}
|
||||||
|
// 保留服务端原文与换行,空白内容单独展示提示。
|
||||||
|
content.value = result.content;
|
||||||
|
} catch (error) {
|
||||||
|
failed.value = true;
|
||||||
|
console.error("加载体验中心 VIP 介绍失败", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad(loadIntroduction);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" bgColor="#22222e" :isHome="true">
|
||||||
|
<template #header><CoinHeader title="体验中心VIP介绍" /></template>
|
||||||
|
<view class="vip-intro">
|
||||||
|
<text v-if="loading">加载中...</text>
|
||||||
|
<text v-else-if="failed" @click="loadIntroduction">加载失败,点击重试</text>
|
||||||
|
<text v-else-if="content.trim()">{{ content }}</text>
|
||||||
|
<text v-else>暂无介绍内容</text>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vip-intro { padding: 32rpx 28rpx; color: #ffffff; font-size: 28rpx; line-height: 44rpx; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,16 @@ export function useDeviceBinding({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showAlreadyBoundPage = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/device/device-already-bound",
|
||||||
|
fail: (error) => {
|
||||||
|
console.error("打开设备已绑定页失败", error);
|
||||||
|
uni.showToast({ title: "设备已绑定其他账号,请解绑后再绑定", icon: "none" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleScan = () => {
|
const handleScan = () => {
|
||||||
uni.scanCode({
|
uni.scanCode({
|
||||||
onlyFromCamera: true,
|
onlyFromCamera: true,
|
||||||
@@ -45,10 +55,8 @@ export function useDeviceBinding({
|
|||||||
const result = await bindDeviceAPIV2(token.value);
|
const result = await bindDeviceAPIV2(token.value);
|
||||||
confirmBindTip.value = false;
|
confirmBindTip.value = false;
|
||||||
if (result?.binded) {
|
if (result?.binded) {
|
||||||
uni.showToast({
|
token.value = "";
|
||||||
title: "设备已绑定其他账号,请解绑后再绑定",
|
showAlreadyBoundPage();
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deviceId = String(result?.deviceId || "").trim();
|
const deviceId = String(result?.deviceId || "").trim();
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ export function useDeviceStatus({
|
|||||||
const maskedDeviceId = computed(() => {
|
const maskedDeviceId = computed(() => {
|
||||||
const id = String(device.value.deviceId || "");
|
const id = String(device.value.deviceId || "");
|
||||||
if (!id) return "暂无设备编号";
|
if (!id) return "暂无设备编号";
|
||||||
if (id.length <= 3) return id;
|
return "*****";
|
||||||
return `${"*".repeat(Math.min(5, id.length - 3))}${id.slice(-3)}`;
|
|
||||||
});
|
});
|
||||||
const deviceRows = computed(() => [
|
const deviceRows = computed(() => [
|
||||||
{ label: "设备型号", value: deviceDetails.value.model || "射灵智能弓" },
|
{ label: "设备型号", value: deviceDetails.value.model || "射灵智能弓" },
|
||||||
@@ -99,7 +98,7 @@ export function useDeviceStatus({
|
|||||||
|
|
||||||
const refreshDeviceDetails = async () => {
|
const refreshDeviceDetails = async () => {
|
||||||
const deviceId = device.value.deviceId;
|
const deviceId = device.value.deviceId;
|
||||||
if (!deviceId) return;
|
if (!deviceId) return null;
|
||||||
|
|
||||||
const requestVersion = ++deviceDetailRequestVersion;
|
const requestVersion = ++deviceDetailRequestVersion;
|
||||||
try {
|
try {
|
||||||
@@ -111,13 +110,17 @@ export function useDeviceStatus({
|
|||||||
requestVersion !== deviceDetailRequestVersion ||
|
requestVersion !== deviceDetailRequestVersion ||
|
||||||
device.value.deviceId !== deviceId
|
device.value.deviceId !== deviceId
|
||||||
) {
|
) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceDetails.value = {
|
deviceDetails.value = {
|
||||||
...deviceDetails.value,
|
...deviceDetails.value,
|
||||||
...detail,
|
...detail,
|
||||||
deviceModelName: detail.deviceModelName ?? "",
|
deviceModelName: detail.deviceModelName ?? "",
|
||||||
|
latestVersion: detail.latestVersion ?? "",
|
||||||
|
needUpdate: detail.needUpdate ?? false,
|
||||||
|
forceUpdate: detail.forceUpdate ?? false,
|
||||||
|
version: detail.version ?? "",
|
||||||
bindTime: String(
|
bindTime: String(
|
||||||
detail.bindTime ?? deviceDetails.value.bindTime ?? ""
|
detail.bindTime ?? deviceDetails.value.bindTime ?? ""
|
||||||
).trim(),
|
).trim(),
|
||||||
@@ -126,9 +129,11 @@ export function useDeviceStatus({
|
|||||||
).trim(),
|
).trim(),
|
||||||
};
|
};
|
||||||
updateDeviceUsageDuration(detail.onlineDuration, deviceId);
|
updateDeviceUsageDuration(detail.onlineDuration, deviceId);
|
||||||
|
return detail;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 实时刷新失败时保留当前页面数据,等待下次通知或页面重新显示。
|
// 实时刷新失败时保留当前页面数据,等待下次通知或页面重新显示。
|
||||||
console.log("刷新设备详情失败", error);
|
console.log("刷新设备详情失败", error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,6 +148,10 @@ export function useDeviceStatus({
|
|||||||
let latestDevice = {
|
let latestDevice = {
|
||||||
...currentDevice,
|
...currentDevice,
|
||||||
deviceModelName: "",
|
deviceModelName: "",
|
||||||
|
latestVersion: "",
|
||||||
|
needUpdate: false,
|
||||||
|
forceUpdate: false,
|
||||||
|
version: "",
|
||||||
bindTime: "",
|
bindTime: "",
|
||||||
qrCodeUrl: "",
|
qrCodeUrl: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup>
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
|
||||||
|
// 结果页从设备页进入,返回时优先保留原页面状态。
|
||||||
|
const goBackToDevicePage = () => {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({ delta: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回到设备页后复用其扫码绑定流程。
|
||||||
|
const retryScan = () => {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
success: () => uni.$emit("device-bind-retry-scan"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.redirectTo({ url: "/pages/device/my-device?retryScan=1" });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="device-already-bound-page">
|
||||||
|
<Container
|
||||||
|
:bgType="12"
|
||||||
|
bgColor="transparent"
|
||||||
|
:onBack="goBackToDevicePage"
|
||||||
|
headerClass="already-bound-header"
|
||||||
|
:scroll="false"
|
||||||
|
:usePageScroll="true"
|
||||||
|
>
|
||||||
|
<view class="already-bound-page">
|
||||||
|
<view class="already-bound-scene">
|
||||||
|
<view class="already-bound-hero-wrap">
|
||||||
|
<view class="already-bound-hero-shadow"></view>
|
||||||
|
<image
|
||||||
|
class="already-bound-hero"
|
||||||
|
src="/static/device-assets/device-already-bound-hero.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="already-bound-message">
|
||||||
|
<text>该设备已经绑定了其他账号,</text>
|
||||||
|
<text>如需绑定本账号,请先解除绑定。</text>
|
||||||
|
</view>
|
||||||
|
<view class="already-bound-retry" @click="$clickSound(retryScan)">
|
||||||
|
<text>重新扫码</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.device-already-bound-page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 与现有绑定结果页保持相同的 375 宽画布尺寸。 */
|
||||||
|
.already-bound-page {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-scene {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-hero-wrap,
|
||||||
|
.already-bound-message,
|
||||||
|
.already-bound-retry {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-hero-wrap {
|
||||||
|
top: 568rpx;
|
||||||
|
left: 244rpx;
|
||||||
|
width: 260rpx;
|
||||||
|
height: 222rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-hero-shadow {
|
||||||
|
position: absolute;
|
||||||
|
left: 22rpx;
|
||||||
|
bottom: 0;
|
||||||
|
width: 238rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-hero {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 260rpx;
|
||||||
|
height: 222rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-message {
|
||||||
|
top: 804rpx;
|
||||||
|
left: 110rpx;
|
||||||
|
display: flex;
|
||||||
|
width: 530rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-family: PingFang SC-Regular;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-bound-retry {
|
||||||
|
top: 930rpx;
|
||||||
|
left: 195rpx;
|
||||||
|
display: flex;
|
||||||
|
width: 360rpx;
|
||||||
|
height: 70rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2rpx solid #ffd947;
|
||||||
|
border-radius: 78rpx;
|
||||||
|
color: #ffd947;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 26rpx;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,7 +8,6 @@ import ScreenHint from "@/components/ScreenHint.vue";
|
|||||||
import ModalDialog from "@/components/ModalDialog.vue";
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
import OtaModal from "@/components/OtaModal.vue";
|
import OtaModal from "@/components/OtaModal.vue";
|
||||||
import {
|
import {
|
||||||
getHardwareBoxVersionAPI,
|
|
||||||
laserAimAPI,
|
laserAimAPI,
|
||||||
updateDeviceAliasAPI,
|
updateDeviceAliasAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
@@ -19,7 +18,7 @@ import {
|
|||||||
DEVICE_NAME_STORAGE_KEY,
|
DEVICE_NAME_STORAGE_KEY,
|
||||||
useDeviceStatus,
|
useDeviceStatus,
|
||||||
} from "./composables/useDeviceStatus";
|
} from "./composables/useDeviceStatus";
|
||||||
import { useOtaUpdate } from "@/composables/useOtaUpdate";
|
import { getOtaInfoFromDetail, useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { updateDevice, updateDeviceUsageDuration, clearDevice } = store;
|
const { updateDevice, updateDeviceUsageDuration, clearDevice } = store;
|
||||||
@@ -62,11 +61,8 @@ const showDeviceId = ref(false);
|
|||||||
const latestVersionDialogVisible = ref(false);
|
const latestVersionDialogVisible = ref(false);
|
||||||
const firmwareConfirmVisible = ref(false);
|
const firmwareConfirmVisible = ref(false);
|
||||||
const wifiRequiredVisible = ref(false);
|
const wifiRequiredVisible = ref(false);
|
||||||
const otaNeedUpdate = ref(false);
|
|
||||||
const firmwareActionPending = ref(false);
|
const firmwareActionPending = ref(false);
|
||||||
const pendingOtaInfo = ref(null);
|
const pendingOtaInfo = ref(null);
|
||||||
let otaCheckPromise = null;
|
|
||||||
let otaCheckRequestVersion = 0;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updating: otaUpdating,
|
updating: otaUpdating,
|
||||||
@@ -113,6 +109,12 @@ const wifiStatusText = computed(() => {
|
|||||||
const bindingDateText = computed(() =>
|
const bindingDateText = computed(() =>
|
||||||
formatBindingDate(deviceDetails.value.bindTime)
|
formatBindingDate(deviceDetails.value.bindTime)
|
||||||
);
|
);
|
||||||
|
const otaNeedUpdate = computed(() => {
|
||||||
|
const info = getOtaInfoFromDetail(deviceDetails.value);
|
||||||
|
return isDeviceOnline.value &&
|
||||||
|
deviceDetails.value.deviceId === device.value.deviceId &&
|
||||||
|
info.needUpdate && !!info.versionNumber;
|
||||||
|
});
|
||||||
|
|
||||||
// 固件版本统一以大写 V 开头,避免接口返回格式不一致影响弹窗展示。
|
// 固件版本统一以大写 V 开头,避免接口返回格式不一致影响弹窗展示。
|
||||||
const formatFirmwareVersion = (value) => {
|
const formatFirmwareVersion = (value) => {
|
||||||
@@ -122,18 +124,12 @@ const formatFirmwareVersion = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const firmwareConfirmContent = computed(() => {
|
const firmwareConfirmContent = computed(() => {
|
||||||
const currentVersion = formatFirmwareVersion(deviceStatus.value.version);
|
const currentVersion = formatFirmwareVersion(pendingOtaInfo.value?.currentVersion);
|
||||||
const latestVersion = formatFirmwareVersion(
|
const latestVersion = formatFirmwareVersion(pendingOtaInfo.value?.versionNumber);
|
||||||
pendingOtaInfo.value?.versionNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentVersion && latestVersion) {
|
if (currentVersion && latestVersion) {
|
||||||
return `当前固件版本为 ${currentVersion},发现新固件版本 ${latestVersion},是否立即更新固件?`;
|
return `当前固件版本为 ${currentVersion},发现新固件版本 ${latestVersion},是否立即更新固件?`;
|
||||||
}
|
}
|
||||||
if (latestVersion) {
|
return latestVersion ? `发现新固件版本 ${latestVersion},是否立即更新固件?` : "是否立即更新固件?";
|
||||||
return `发现新固件版本 ${latestVersion},是否立即更新固件?`;
|
|
||||||
}
|
|
||||||
return "发现新固件版本,是否立即更新固件?";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceIdText = computed(() =>
|
const deviceIdText = computed(() =>
|
||||||
@@ -272,84 +268,20 @@ const joinWifi = () => {
|
|||||||
uni.navigateTo({ url: "/pages/device/ota-wifi" });
|
uni.navigateTo({ url: "/pages/device/ota-wifi" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearOtaState = () => {
|
const closeFirmwareConfirm = () => {
|
||||||
otaCheckRequestVersion += 1;
|
firmwareConfirmVisible.value = false;
|
||||||
otaCheckPromise = null;
|
|
||||||
otaNeedUpdate.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadOtaVersionInfo = async () => {
|
|
||||||
if (otaCheckPromise) return otaCheckPromise;
|
|
||||||
|
|
||||||
const deviceId = device.value.deviceId;
|
|
||||||
if (!deviceId || !isDeviceOnline.value) return null;
|
|
||||||
|
|
||||||
const requestVersion = ++otaCheckRequestVersion;
|
|
||||||
const request = getHardwareBoxVersionAPI()
|
|
||||||
.then((versionInfo) => {
|
|
||||||
if (
|
|
||||||
requestVersion !== otaCheckRequestVersion ||
|
|
||||||
device.value.deviceId !== deviceId ||
|
|
||||||
!isDeviceOnline.value
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!versionInfo || typeof versionInfo !== "object") {
|
|
||||||
throw new Error("固件版本信息为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
const needUpdate =
|
|
||||||
versionInfo.needUpdate === true || Number(versionInfo.needUpdate) === 1;
|
|
||||||
otaNeedUpdate.value = needUpdate;
|
|
||||||
return {
|
|
||||||
versionNumber: versionInfo.versionNumber || "",
|
|
||||||
resourceUrl: versionInfo.resourceUrl || "",
|
|
||||||
needUpdate,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (otaCheckPromise === request) {
|
|
||||||
otaCheckPromise = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
otaCheckPromise = request;
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshOtaUpdateState = async () => {
|
|
||||||
otaNeedUpdate.value = false;
|
|
||||||
if (!device.value.deviceId || !isDeviceOnline.value) {
|
|
||||||
clearOtaState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadOtaVersionInfo();
|
|
||||||
} catch (error) {
|
|
||||||
clearOtaState();
|
|
||||||
console.log("检查固件更新失败", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeLatestVersionDialog = () => {
|
const closeLatestVersionDialog = () => {
|
||||||
latestVersionDialogVisible.value = false;
|
latestVersionDialogVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeFirmwareConfirm = () => {
|
|
||||||
firmwareConfirmVisible.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runFirmwareUpdate = () => {
|
const runFirmwareUpdate = () => {
|
||||||
const versionInfo = pendingOtaInfo.value;
|
const otaInfo = pendingOtaInfo.value;
|
||||||
if (!versionInfo) return;
|
if (!otaInfo || otaInfo.deviceId !== device.value.deviceId) return;
|
||||||
void startOtaUpdate({
|
void startOtaUpdate({
|
||||||
versionNumber: versionInfo.versionNumber,
|
versionNumber: otaInfo.versionNumber,
|
||||||
resourceUrl: versionInfo.resourceUrl,
|
onSuccess: () => void refreshDeviceDetails(),
|
||||||
onSuccess: () => {
|
|
||||||
otaNeedUpdate.value = false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -367,42 +299,43 @@ const confirmFirmwareUpdate = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goWifiForFirmwareUpdate = () => {
|
const goWifiForFirmwareUpdate = () => {
|
||||||
const versionInfo = pendingOtaInfo.value;
|
const otaInfo = pendingOtaInfo.value;
|
||||||
if (!versionInfo) return;
|
if (!otaInfo || otaInfo.deviceId !== device.value.deviceId) return;
|
||||||
wifiRequiredVisible.value = false;
|
wifiRequiredVisible.value = false;
|
||||||
const query = [
|
const versionNumber = encodeURIComponent(otaInfo.versionNumber);
|
||||||
"source=firmware-update",
|
uni.navigateTo({ url: `/pages/device/ota-wifi?source=firmware-update&versionNumber=${versionNumber}` });
|
||||||
`versionNumber=${encodeURIComponent(versionInfo.versionNumber)}`,
|
|
||||||
`resourceUrl=${encodeURIComponent(versionInfo.resourceUrl)}`,
|
|
||||||
].join("&");
|
|
||||||
uni.navigateTo({ url: `/pages/device/ota-wifi?${query}` });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOtaResultClose = () => {
|
const handleOtaResultClose = () => {
|
||||||
closeOtaResult();
|
closeOtaResult();
|
||||||
void refreshOtaUpdateState();
|
void refreshDeviceDetails();
|
||||||
};
|
};
|
||||||
|
|
||||||
const goFirmwareUpdate = async () => {
|
const goFirmwareUpdate = async () => {
|
||||||
if (firmwareActionPending.value) return;
|
if (firmwareActionPending.value || otaUpdating.value) return;
|
||||||
if (!isDeviceOnline.value) {
|
if (!isDeviceOnline.value) {
|
||||||
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
firmwareActionPending.value = true;
|
firmwareActionPending.value = true;
|
||||||
|
const deviceId = device.value.deviceId;
|
||||||
try {
|
try {
|
||||||
const versionInfo = await loadOtaVersionInfo();
|
const detail = await refreshDeviceDetails();
|
||||||
if (!versionInfo) return;
|
if (!detail || device.value.deviceId !== deviceId) {
|
||||||
if (!versionInfo.needUpdate) {
|
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const otaInfo = getOtaInfoFromDetail(detail);
|
||||||
|
if (!otaInfo.needUpdate) {
|
||||||
latestVersionDialogVisible.value = true;
|
latestVersionDialogVisible.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!otaInfo.versionNumber) {
|
||||||
pendingOtaInfo.value = versionInfo;
|
uni.showToast({ title: "缺少固件目标版本,请重试", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingOtaInfo.value = { ...otaInfo, deviceId };
|
||||||
firmwareConfirmVisible.value = true;
|
firmwareConfirmVisible.value = true;
|
||||||
} catch (error) {
|
|
||||||
uni.showToast({ title: "获取更新版本失败,请重试", icon: "none" });
|
|
||||||
} finally {
|
} finally {
|
||||||
firmwareActionPending.value = false;
|
firmwareActionPending.value = false;
|
||||||
}
|
}
|
||||||
@@ -451,15 +384,20 @@ watch(
|
|||||||
(isOnline, wasOnline) => {
|
(isOnline, wasOnline) => {
|
||||||
if (isOnline && !wasOnline) {
|
if (isOnline && !wasOnline) {
|
||||||
void refreshDeviceDetails();
|
void refreshDeviceDetails();
|
||||||
void refreshOtaUpdateState();
|
} else if (!isOnline) {
|
||||||
return;
|
firmwareConfirmVisible.value = false;
|
||||||
}
|
wifiRequiredVisible.value = false;
|
||||||
if (!isOnline) {
|
|
||||||
clearOtaState();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(() => device.value.deviceId, () => {
|
||||||
|
pendingOtaInfo.value = null;
|
||||||
|
latestVersionDialogVisible.value = false;
|
||||||
|
firmwareConfirmVisible.value = false;
|
||||||
|
wifiRequiredVisible.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
onLoad((options = {}) => {
|
onLoad((options = {}) => {
|
||||||
retryScanOnShow.value = options.retryScan === "1";
|
retryScanOnShow.value = options.retryScan === "1";
|
||||||
});
|
});
|
||||||
@@ -475,7 +413,6 @@ onUnmounted(() => {
|
|||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
calibration.value = uni.getStorageSync("calibration");
|
calibration.value = uni.getStorageSync("calibration");
|
||||||
await syncDeviceBinding();
|
await syncDeviceBinding();
|
||||||
void refreshOtaUpdateState();
|
|
||||||
if (retryScanOnShow.value) {
|
if (retryScanOnShow.value) {
|
||||||
retryScanOnShow.value = false;
|
retryScanOnShow.value = false;
|
||||||
handleScan();
|
handleScan();
|
||||||
@@ -510,12 +447,12 @@ onShow(async () => {
|
|||||||
<text class="highlight-text">射灵智能弓箭</text>
|
<text class="highlight-text">射灵智能弓箭</text>
|
||||||
<text>设备上的二维码</text>
|
<text>设备上的二维码</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="benefit-copy">
|
<!-- <view class="benefit-copy">
|
||||||
<text>新设备首次绑定账号可获赠</text>
|
<text>新设备首次绑定账号可获赠</text>
|
||||||
<text class="highlight-text">6个月射灵会员礼包</text>
|
<text class="highlight-text">6个月射灵会员礼包</text>
|
||||||
<text>,</text>
|
<text>,</text>
|
||||||
<text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text>
|
<text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text>
|
||||||
</view>
|
</view> -->
|
||||||
<button class="help-link" hover-class="none" @click="showTip = true">
|
<button class="help-link" hover-class="none" @click="showTip = true">
|
||||||
<text>没找到二维码或遇到问题></text>
|
<text>没找到二维码或遇到问题></text>
|
||||||
</button>
|
</button>
|
||||||
@@ -607,10 +544,7 @@ onShow(async () => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="action-label-wrap">
|
<view class="action-label-wrap">
|
||||||
<text>固件更新</text>
|
<text>固件更新</text>
|
||||||
<text
|
<text v-if="otaNeedUpdate" class="action-badge action-badge--new">New</text>
|
||||||
v-if="otaNeedUpdate"
|
|
||||||
class="action-badge action-badge--new"
|
|
||||||
>New</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="action-item" @click="joinWifi">
|
<view class="action-item" @click="joinWifi">
|
||||||
@@ -724,6 +658,7 @@ onShow(async () => {
|
|||||||
:content="firmwareConfirmContent"
|
:content="firmwareConfirmContent"
|
||||||
cancelText="暂不更新"
|
cancelText="暂不更新"
|
||||||
confirmText="立即更新"
|
confirmText="立即更新"
|
||||||
|
:showCancel="!pendingOtaInfo?.forceUpdate"
|
||||||
:onCancel="closeFirmwareConfirm"
|
:onCancel="closeFirmwareConfirm"
|
||||||
:onConfirm="confirmFirmwareUpdate"
|
:onConfirm="confirmFirmwareUpdate"
|
||||||
></ModalDialog>
|
></ModalDialog>
|
||||||
@@ -931,7 +866,7 @@ onShow(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-link {
|
.help-link {
|
||||||
margin-top: 72rpx;
|
margin-top: 36rpx;
|
||||||
color: #ffd947;
|
color: #ffd947;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
line-height: 36rpx;
|
line-height: 36rpx;
|
||||||
@@ -939,8 +874,8 @@ onShow(async () => {
|
|||||||
|
|
||||||
.product-link {
|
.product-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 104rpx;
|
bottom: 208rpx;
|
||||||
bottom: calc(env(safe-area-inset-bottom) + 104rpx);
|
/* bottom: calc(env(safe-area-inset-bottom) + 104rpx); */
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import useStore from "@/store";
|
|||||||
import { capsuleHeight } from "@/util";
|
import { capsuleHeight } from "@/util";
|
||||||
import {
|
import {
|
||||||
connectDeviceWifiAPI,
|
connectDeviceWifiAPI,
|
||||||
|
getDeviceDetailAPI,
|
||||||
getDeviceWifiStatusAPI,
|
getDeviceWifiStatusAPI,
|
||||||
getHardwareBoxVersionAPI,
|
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
import { useOtaUpdate } from "@/composables/useOtaUpdate";
|
import { getOtaInfoFromDetail, useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||||
|
|
||||||
const STATES = {
|
const STATES = {
|
||||||
SCANNING: "SCANNING",
|
SCANNING: "SCANNING",
|
||||||
@@ -23,7 +23,7 @@ const STATES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||||
const { deviceStatus } = storeToRefs(useStore());
|
const { device, deviceStatus } = storeToRefs(useStore());
|
||||||
const currentState = ref(STATES.SCANNING);
|
const currentState = ref(STATES.SCANNING);
|
||||||
const connectedWifi = ref(null);
|
const connectedWifi = ref(null);
|
||||||
const connectedWifiName = ref("");
|
const connectedWifiName = ref("");
|
||||||
@@ -39,16 +39,14 @@ const connectMode = ref("secure"); // secure | open | manual
|
|||||||
const connectError = ref("");
|
const connectError = ref("");
|
||||||
const connectStatusText = ref("");
|
const connectStatusText = ref("");
|
||||||
const isSubmittingWifi = ref(false);
|
const isSubmittingWifi = ref(false);
|
||||||
|
const wifiSuccessVisible = ref(false);
|
||||||
const keyboardHeight = ref(0);
|
const keyboardHeight = ref(0);
|
||||||
// 控制密码输入框是否显示明文
|
// 控制密码输入框是否显示明文
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
// 刷新防抖标志:扫描进行中为 true,禁止重复点击;扫描结束(成功/失败)后重置为 false。
|
// 刷新防抖标志:扫描进行中为 true,禁止重复点击;扫描结束(成功/失败)后重置为 false。
|
||||||
const isRefreshing = ref(false);
|
const isRefreshing = ref(false);
|
||||||
const fromFirmwareUpdate = ref(false);
|
const fromFirmwareUpdate = ref(false);
|
||||||
const routeOtaInfo = ref({
|
const routeOtaVersionNumber = ref("");
|
||||||
versionNumber: "",
|
|
||||||
resourceUrl: "",
|
|
||||||
});
|
|
||||||
const countdownVisible = ref(false);
|
const countdownVisible = ref(false);
|
||||||
const countdownSeconds = ref(3);
|
const countdownSeconds = ref(3);
|
||||||
const firmwareMessageVisible = ref(false);
|
const firmwareMessageVisible = ref(false);
|
||||||
@@ -367,10 +365,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
|
|||||||
secure: connectMode.value === "secure" || !!password,
|
secure: connectMode.value === "secure" || !!password,
|
||||||
};
|
};
|
||||||
connectError.value = "";
|
connectError.value = "";
|
||||||
currentState.value = STATES.CONNECTED;
|
|
||||||
void refreshConnectedWifiName();
|
void refreshConnectedWifiName();
|
||||||
if (fromFirmwareUpdate.value) {
|
if (fromFirmwareUpdate.value) {
|
||||||
|
currentState.value = STATES.CONNECTED;
|
||||||
openFirmwareCountdown();
|
openFirmwareCountdown();
|
||||||
|
} else {
|
||||||
|
// 保留配网输入界面,避免成功提示出现前先露出 WiFi 列表。
|
||||||
|
wifiSuccessVisible.value = true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId === wifiConnectRequestId) {
|
if (requestId === wifiConnectRequestId) {
|
||||||
@@ -385,6 +386,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 普通配网成功后,确认并收起输入界面,停留在当前 WiFi 页面。
|
||||||
|
const confirmWifiSuccess = () => {
|
||||||
|
if (!wifiSuccessVisible.value) return;
|
||||||
|
wifiSuccessVisible.value = false;
|
||||||
|
currentState.value = STATES.CONNECTED;
|
||||||
|
};
|
||||||
|
|
||||||
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
|
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
|
||||||
const joinNetwork = () => {
|
const joinNetwork = () => {
|
||||||
if (joinDisabled.value) return;
|
if (joinDisabled.value) return;
|
||||||
@@ -395,37 +403,38 @@ const joinNetwork = () => {
|
|||||||
submitDeviceWifiConfig({ ssid, password });
|
submitDeviceWifiConfig({ ssid, password });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取 OTA 更新版本信息,正常使用入口传参,缺失时再请求后端兜底。
|
|
||||||
const getOtaVersionInfo = async () => {
|
|
||||||
if (routeOtaInfo.value.versionNumber && routeOtaInfo.value.resourceUrl) {
|
|
||||||
return {
|
|
||||||
needUpdate: true,
|
|
||||||
versionNumber: routeOtaInfo.value.versionNumber,
|
|
||||||
resourceUrl: routeOtaInfo.value.resourceUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return getHardwareBoxVersionAPI();
|
|
||||||
};
|
|
||||||
|
|
||||||
const startFirmwareUpdate = async () => {
|
const startFirmwareUpdate = async () => {
|
||||||
if (!fromFirmwareUpdate.value || !connectedWifi.value || otaUpdating.value) return;
|
if (!fromFirmwareUpdate.value || !connectedWifi.value || otaUpdating.value) return;
|
||||||
|
const deviceId = device.value.deviceId;
|
||||||
|
const wifi = connectedWifi.value;
|
||||||
|
if (!deviceId) return;
|
||||||
try {
|
try {
|
||||||
const versionInfo = await getOtaVersionInfo();
|
const response = await getDeviceDetailAPI(deviceId);
|
||||||
if (!versionInfo?.needUpdate) {
|
const detail = response?.detail || response?.data?.detail;
|
||||||
|
if (!detail || device.value.deviceId !== deviceId) throw new Error("设备详情已失效");
|
||||||
|
const otaInfo = getOtaInfoFromDetail(detail);
|
||||||
|
if (!otaInfo.needUpdate) {
|
||||||
firmwareMessage.value = "当前已是最新版本";
|
firmwareMessage.value = "当前已是最新版本";
|
||||||
firmwareMessageVisible.value = true;
|
firmwareMessageVisible.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startOtaUpdate({
|
if (!otaInfo.versionNumber) {
|
||||||
versionNumber: versionInfo.versionNumber,
|
firmwareMessage.value = "缺少固件目标版本,请返回后重试";
|
||||||
wifiSsid: connectedWifi.value.SSID,
|
|
||||||
wifiPassword: connectedWifi.value.password || "",
|
|
||||||
resourceUrl: versionInfo.resourceUrl,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
firmwareMessage.value = "获取更新版本失败,请重试";
|
|
||||||
firmwareMessageVisible.value = true;
|
firmwareMessageVisible.value = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// WiFi 连接可能耗时较长,以更新前的最新设备详情为准。
|
||||||
|
routeOtaVersionNumber.value = otaInfo.versionNumber;
|
||||||
|
} catch (error) {
|
||||||
|
firmwareMessage.value = "获取设备详情失败,请返回后重试";
|
||||||
|
firmwareMessageVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startOtaUpdate({
|
||||||
|
versionNumber: routeOtaVersionNumber.value,
|
||||||
|
wifiSsid: wifi.SSID,
|
||||||
|
wifiPassword: wifi.password || "",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFirmwareCountdown = () => {
|
const clearFirmwareCountdown = () => {
|
||||||
@@ -466,10 +475,7 @@ const togglePasswordVisibility = () => {
|
|||||||
// 页面加载时识别普通 WiFi 入口和固件更新入口。
|
// 页面加载时识别普通 WiFi 入口和固件更新入口。
|
||||||
onLoad((options = {}) => {
|
onLoad((options = {}) => {
|
||||||
fromFirmwareUpdate.value = options.source === "firmware-update";
|
fromFirmwareUpdate.value = options.source === "firmware-update";
|
||||||
routeOtaInfo.value = {
|
routeOtaVersionNumber.value = decodeURIComponent(options.versionNumber || "");
|
||||||
versionNumber: decodeURIComponent(options.versionNumber || ""),
|
|
||||||
resourceUrl: decodeURIComponent(options.resourceUrl || ""),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 页面挂载后启动 WiFi 扫描流程。
|
// 页面挂载后启动 WiFi 扫描流程。
|
||||||
@@ -760,6 +766,14 @@ onUnmounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<ModalDialog
|
||||||
|
:show="wifiSuccessVisible"
|
||||||
|
content="WIFI连接成功"
|
||||||
|
confirmText="确定"
|
||||||
|
:showCancel="false"
|
||||||
|
:onConfirm="confirmWifiSuccess"
|
||||||
|
></ModalDialog>
|
||||||
|
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
:show="countdownVisible"
|
:show="countdownVisible"
|
||||||
title="WiFi连接成功"
|
title="WiFi连接成功"
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import OtaModal from "@/components/OtaModal.vue";
|
|||||||
import {
|
import {
|
||||||
checkUserBindAPI,
|
checkUserBindAPI,
|
||||||
getAppConfig,
|
getAppConfig,
|
||||||
getHardwareBoxVersionAPI,
|
getDeviceDetailAPI,
|
||||||
getHomeData,
|
getHomeData,
|
||||||
getMyDevicesAPI,
|
getMyDevicesAPI,
|
||||||
getScoreRankList,
|
getScoreRankList,
|
||||||
silentLoginAPI,
|
silentLoginAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
import {topThreeColors} from "@/constants";
|
import {topThreeColors} from "@/constants";
|
||||||
import {useOtaUpdate} from "@/composables/useOtaUpdate";
|
import {getOtaInfoFromDetail, useOtaUpdate} from "@/composables/useOtaUpdate";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
@@ -74,16 +74,9 @@ const isDeviceCharging = computed(
|
|||||||
() => deviceCardState.value === "online" && deviceStatus.value?.charging === true
|
() => deviceCardState.value === "online" && deviceStatus.value?.charging === true
|
||||||
);
|
);
|
||||||
|
|
||||||
// OTA 相关
|
|
||||||
const otaVisible = ref(false);
|
const otaVisible = ref(false);
|
||||||
const wifiRequiredVisible = ref(false);
|
const wifiRequiredVisible = ref(false);
|
||||||
const otaInfo = ref({
|
const otaInfo = ref({ versionNumber: "", versionInfo: "", needUpdate: false, forceUpdate: false });
|
||||||
versionNumber: "",
|
|
||||||
versionInfo: "",
|
|
||||||
resourceUrl: "",
|
|
||||||
forceUpdate: false,
|
|
||||||
needUpdate: false,
|
|
||||||
});
|
|
||||||
const isStartingOta = ref(false);
|
const isStartingOta = ref(false);
|
||||||
let isCheckingOta = false;
|
let isCheckingOta = false;
|
||||||
let otaCheckQueuedForOnline = false;
|
let otaCheckQueuedForOnline = false;
|
||||||
@@ -98,157 +91,122 @@ const {
|
|||||||
closeResult: closeOtaResult,
|
closeResult: closeOtaResult,
|
||||||
} = useOtaUpdate();
|
} = useOtaUpdate();
|
||||||
|
|
||||||
// 获取并保存后端返回的 OTA 版本信息,供弹窗展示和更新接口使用。
|
// 首页仅用绑定设备详情判断是否需要升级,不再请求旧版本接口。
|
||||||
const applyOtaVersionInfo = (versionInfo) => {
|
|
||||||
otaInfo.value = {
|
|
||||||
versionNumber: versionInfo?.versionNumber || "",
|
|
||||||
versionInfo: versionInfo?.versionInfo || "",
|
|
||||||
resourceUrl: versionInfo?.resourceUrl || "",
|
|
||||||
forceUpdate: Number(versionInfo?.forceUpdate) === 1,
|
|
||||||
needUpdate:
|
|
||||||
versionInfo?.needUpdate === true || Number(versionInfo?.needUpdate) === 1,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查当前设备盒子是否存在可升级版本。
|
|
||||||
const checkOtaUpdate = async () => {
|
const checkOtaUpdate = async () => {
|
||||||
if (
|
if (isCheckingOta || otaVisible.value || otaUpdating.value || otaResultVisible.value) return;
|
||||||
isCheckingOta ||
|
const deviceId = String(device.value.deviceId || "");
|
||||||
otaVisible.value ||
|
if (!user.value.id || !deviceId || online.value !== true) return;
|
||||||
otaUpdating.value ||
|
|
||||||
otaResultVisible.value
|
|
||||||
) return;
|
|
||||||
isCheckingOta = true;
|
isCheckingOta = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
const response = await getDeviceDetailAPI(deviceId);
|
||||||
online.value !== true ||
|
const detail = response?.detail || response?.data?.detail;
|
||||||
otaVisible.value ||
|
if (!detail || device.value.deviceId !== deviceId || online.value !== true) return;
|
||||||
otaUpdating.value ||
|
|
||||||
otaResultVisible.value
|
|
||||||
) return;
|
|
||||||
|
|
||||||
let versionInfo;
|
|
||||||
try {
|
|
||||||
versionInfo = await getHardwareBoxVersionAPI();
|
|
||||||
} catch (err) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (otaVisible.value || otaUpdating.value || otaResultVisible.value) return;
|
if (otaVisible.value || otaUpdating.value || otaResultVisible.value) return;
|
||||||
applyOtaVersionInfo(versionInfo);
|
otaInfo.value = getOtaInfoFromDetail(detail);
|
||||||
if (!otaInfo.value.needUpdate) return;
|
if (!otaInfo.value.needUpdate || !otaInfo.value.versionNumber) return;
|
||||||
|
|
||||||
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
|
const dismissed = uni.getStorageSync("ota_dismissed_at");
|
||||||
const now = Date.now();
|
const recentlyDismissed = dismissed?.deviceId === deviceId &&
|
||||||
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
|
dismissed?.versionNumber === otaInfo.value.versionNumber &&
|
||||||
|
Date.now() - Number(dismissed?.at || 0) < 24 * 60 * 60 * 1000;
|
||||||
|
if (!otaInfo.value.forceUpdate && recentlyDismissed) return;
|
||||||
otaVisible.value = true;
|
otaVisible.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("检查固件更新失败", error);
|
||||||
} finally {
|
} finally {
|
||||||
isCheckingOta = false;
|
isCheckingOta = false;
|
||||||
const shouldRecheckForOnline = otaCheckQueuedForOnline;
|
if (otaCheckQueuedForOnline) {
|
||||||
otaCheckQueuedForOnline = false;
|
otaCheckQueuedForOnline = false;
|
||||||
if (
|
|
||||||
shouldRecheckForOnline &&
|
|
||||||
!otaVisible.value &&
|
|
||||||
!otaUpdating.value &&
|
|
||||||
!otaResultVisible.value
|
|
||||||
) {
|
|
||||||
void checkOtaUpdate();
|
void checkOtaUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设备由 WS 通知上线时补查 OTA;已显示任何 OTA 弹窗时保留当前流程和结果。
|
|
||||||
watch(online, (nextOnline, previousOnline) => {
|
watch(online, (nextOnline, previousOnline) => {
|
||||||
if (
|
if (!nextOnline) {
|
||||||
previousOnline !== false ||
|
otaVisible.value = false;
|
||||||
nextOnline !== true ||
|
|
||||||
otaVisible.value ||
|
|
||||||
otaUpdating.value ||
|
|
||||||
otaResultVisible.value
|
|
||||||
) return;
|
|
||||||
if (isCheckingOta) {
|
|
||||||
otaCheckQueuedForOnline = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void checkOtaUpdate();
|
if (previousOnline === false) {
|
||||||
|
if (isCheckingOta) otaCheckQueuedForOnline = true;
|
||||||
|
else void checkOtaUpdate();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 任一页面发起或恢复 OTA 后,关闭首页的版本发现弹窗,避免两层弹窗重叠。
|
watch(() => device.value.deviceId, () => {
|
||||||
watch([otaUpdating, otaResultVisible], ([updating, resultVisible]) => {
|
|
||||||
if (!updating && !resultVisible) return;
|
|
||||||
otaVisible.value = false;
|
otaVisible.value = false;
|
||||||
isStartingOta.value = false;
|
otaInfo.value = { versionNumber: "", versionInfo: "", needUpdate: false, forceUpdate: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
|
watch([otaUpdating, otaResultVisible], ([updating, resultVisible]) => {
|
||||||
const getOtaWifiUrl = () => {
|
if (updating || resultVisible) otaVisible.value = false;
|
||||||
const { versionNumber, resourceUrl } = otaInfo.value;
|
});
|
||||||
const query = [
|
|
||||||
"source=firmware-update",
|
const getOtaWifiUrl = () =>
|
||||||
`versionNumber=${encodeURIComponent(versionNumber)}`,
|
`/pages/device/ota-wifi?source=firmware-update&versionNumber=${encodeURIComponent(otaInfo.value.versionNumber)}`;
|
||||||
`resourceUrl=${encodeURIComponent(resourceUrl)}`,
|
|
||||||
].join("&");
|
|
||||||
return `/pages/device/ota-wifi?${query}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理 OTA 弹窗暂不更新,强制更新时不允许关闭。
|
|
||||||
const handleOtaDismiss = () => {
|
const handleOtaDismiss = () => {
|
||||||
if (otaInfo.value.forceUpdate) return;
|
if (otaInfo.value.forceUpdate) return;
|
||||||
uni.setStorageSync("ota_dismissed_at", Date.now());
|
uni.setStorageSync("ota_dismissed_at", {
|
||||||
otaVisible.value = false;
|
deviceId: device.value.deviceId,
|
||||||
};
|
|
||||||
|
|
||||||
// 设备盒子已连 WiFi 时,通过共享 OTA 状态发起更新。
|
|
||||||
const startHomeOtaUpdate = async () => {
|
|
||||||
otaVisible.value = false;
|
|
||||||
await startOtaUpdate({
|
|
||||||
versionNumber: otaInfo.value.versionNumber,
|
versionNumber: otaInfo.value.versionNumber,
|
||||||
resourceUrl: otaInfo.value.resourceUrl,
|
at: Date.now(),
|
||||||
onSuccess: () => {
|
|
||||||
otaInfo.value = {...otaInfo.value, needUpdate: false};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
isStartingOta.value = false;
|
otaVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点击立即更新时先判断设备是否在线并已通过 WiFi 联网,未联网时先展示连接引导。
|
// 点击更新或失败重试时检查设备网络状态,并使用详情中的目标版本号。
|
||||||
const handleOtaUpdate = async () => {
|
const handleOtaUpdate = async () => {
|
||||||
if (isStartingOta.value) return;
|
if (isStartingOta.value) return;
|
||||||
isStartingOta.value = true;
|
isStartingOta.value = true;
|
||||||
const currentDeviceStatus = deviceStatus.value;
|
const deviceId = String(device.value.deviceId || "");
|
||||||
|
try {
|
||||||
if (currentDeviceStatus?.online !== true) {
|
if (!deviceId || deviceStatus.value?.online !== true) {
|
||||||
isStartingOta.value = false;
|
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||||
uni.showToast({
|
|
||||||
title: "请先开启智能弓",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const response = await getDeviceDetailAPI(deviceId);
|
||||||
if (
|
const detail = response?.detail || response?.data?.detail;
|
||||||
Date.now() - Number(currentDeviceStatus?.receivedAt || 0) >
|
if (!detail || device.value.deviceId !== deviceId) {
|
||||||
DEVICE_STATUS_STALE_TIME
|
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||||
) {
|
|
||||||
isStartingOta.value = false;
|
|
||||||
uni.showToast({
|
|
||||||
title: "设备状态同步中,请稍后重试",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
otaInfo.value = getOtaInfoFromDetail(detail);
|
||||||
if (String(currentDeviceStatus?.netType || "").toLowerCase() === "wifi") {
|
if (!otaInfo.value.needUpdate) {
|
||||||
startHomeOtaUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isStartingOta.value = false;
|
|
||||||
otaVisible.value = false;
|
otaVisible.value = false;
|
||||||
|
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!otaInfo.value.versionNumber) {
|
||||||
|
uni.showToast({ title: "缺少固件目标版本", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentDeviceStatus = deviceStatus.value;
|
||||||
|
if (currentDeviceStatus?.online !== true) {
|
||||||
|
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - Number(currentDeviceStatus?.receivedAt || 0) > DEVICE_STATUS_STALE_TIME) {
|
||||||
|
uni.showToast({ title: "设备状态同步中,请稍后重试", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
otaVisible.value = false;
|
||||||
|
if (String(currentDeviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||||
|
await startOtaUpdate({
|
||||||
|
versionNumber: otaInfo.value.versionNumber,
|
||||||
|
onSuccess: () => { otaInfo.value = { ...otaInfo.value, needUpdate: false }; },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
wifiRequiredVisible.value = true;
|
wifiRequiredVisible.value = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||||
|
} finally {
|
||||||
|
isStartingOta.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从首页固件更新提示进入 WiFi 配置页,并继续沿用当前版本信息。
|
// 从首页更新失败提示进入 WiFi 配置页。
|
||||||
const goWifiForOtaUpdate = () => {
|
const goWifiForOtaUpdate = () => {
|
||||||
wifiRequiredVisible.value = false;
|
wifiRequiredVisible.value = false;
|
||||||
uni.navigateTo({ url: getOtaWifiUrl() });
|
uni.navigateTo({ url: getOtaWifiUrl() });
|
||||||
@@ -257,22 +215,13 @@ const goWifiForOtaUpdate = () => {
|
|||||||
// 处理 OTA 更新成功后的完成按钮,关闭共享结果弹窗。
|
// 处理 OTA 更新成功后的完成按钮,关闭共享结果弹窗。
|
||||||
const handleOtaDone = () => {
|
const handleOtaDone = () => {
|
||||||
closeOtaResult();
|
closeOtaResult();
|
||||||
|
otaInfo.value = { ...otaInfo.value, needUpdate: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理共享 OTA 更新失败后的重试,先刷新版本参数再重新判断网络状态。
|
// 处理共享 OTA 更新失败后的重试。
|
||||||
const handleOtaRetry = async () => {
|
const handleOtaRetry = async () => {
|
||||||
closeOtaResult();
|
closeOtaResult();
|
||||||
try {
|
|
||||||
const versionInfo = await getHardwareBoxVersionAPI();
|
|
||||||
applyOtaVersionInfo(versionInfo);
|
|
||||||
if (!otaInfo.value.needUpdate) return;
|
|
||||||
await handleOtaUpdate();
|
await handleOtaUpdate();
|
||||||
} catch (error) {
|
|
||||||
uni.showToast({
|
|
||||||
title: "获取更新版本失败,请重试",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||||
@@ -346,10 +295,6 @@ onShow(async () => {
|
|||||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
const token = uni.getStorageSync(`${env}_token`);
|
const token = uni.getStorageSync(`${env}_token`);
|
||||||
|
|
||||||
if (token || user.value.id) {
|
|
||||||
await checkOtaUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.value.id && !token) {
|
if (!user.value.id && !token) {
|
||||||
// showModal.value = true;
|
// showModal.value = true;
|
||||||
// try {
|
// try {
|
||||||
@@ -401,6 +346,7 @@ onShow(async () => {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
await syncHomeDevice();
|
await syncHomeDevice();
|
||||||
|
await checkOtaUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,12 +378,10 @@ onShareTimeline(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :isHome="true" :showBackToGame="true">
|
<Container :isHome="true" :showBackToGame="true">
|
||||||
<!-- 首页版本发现弹窗仅负责触发更新,执行状态由三个页面共享。 -->
|
|
||||||
<OtaModal
|
<OtaModal
|
||||||
:visible="otaVisible && !otaUpdating && !otaResultVisible"
|
:visible="otaVisible && !otaUpdating && !otaResultVisible"
|
||||||
state="new_version"
|
state="new_version"
|
||||||
:version="otaInfo.versionNumber"
|
:version="otaInfo.versionNumber"
|
||||||
:description="''"
|
|
||||||
:changelog="otaInfo.versionInfo"
|
:changelog="otaInfo.versionInfo"
|
||||||
:forceUpdate="otaInfo.forceUpdate"
|
:forceUpdate="otaInfo.forceUpdate"
|
||||||
@update="handleOtaUpdate"
|
@update="handleOtaUpdate"
|
||||||
@@ -534,7 +478,7 @@ onShareTimeline(() => {
|
|||||||
@click.stop="$clickSound(() => toPage('/pages/device/my-device'))"
|
@click.stop="$clickSound(() => toPage('/pages/device/my-device'))"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
v-if="otaInfo.needUpdate"
|
v-if="otaInfo.needUpdate && otaInfo.versionNumber"
|
||||||
class="device-status-refresh"
|
class="device-status-refresh"
|
||||||
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-state-refresh.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-state-refresh.png"
|
||||||
mode="scaleToFill"
|
mode="scaleToFill"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import audioManager, {
|
import audioManager, {
|
||||||
|
getTrainingEndAudioKey,
|
||||||
getTrainingStartAudioKey,
|
getTrainingStartAudioKey,
|
||||||
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
||||||
} from "@/audioManager";
|
} from "@/audioManager";
|
||||||
@@ -111,6 +112,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["rhythm-round-complete"]);
|
||||||
|
|
||||||
const trainingTitleIconMap = Object.freeze({
|
const trainingTitleIconMap = Object.freeze({
|
||||||
base:
|
base:
|
||||||
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
|
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
|
||||||
@@ -143,6 +146,7 @@ let rhythmCountdownTimer = null;
|
|||||||
let rhythmTransitionTimer = null;
|
let rhythmTransitionTimer = null;
|
||||||
let rhythmServerClockOffsetMs = 0;
|
let rhythmServerClockOffsetMs = 0;
|
||||||
let rhythmSyncGeneration = 0;
|
let rhythmSyncGeneration = 0;
|
||||||
|
let rhythmRoundCompletionTimer = null;
|
||||||
|
|
||||||
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
|
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
|
||||||
const isStabilityTraining = computed(
|
const isStabilityTraining = computed(
|
||||||
@@ -223,7 +227,7 @@ const rhythmTitle = computed(() => {
|
|||||||
if (!validRhythmHitReq.value) {
|
if (!validRhythmHitReq.value) {
|
||||||
return `在最后的${validRhythmShootTime.value}秒内射箭`;
|
return `在最后的${validRhythmShootTime.value}秒内射箭`;
|
||||||
}
|
}
|
||||||
return `在最后的${validRhythmShootTime.value}秒并命中${validRhythmHitReq.value}环内`;
|
return `在最后的${validRhythmShootTime.value}秒射箭并命中${validRhythmHitReq.value}环内`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
@@ -306,6 +310,30 @@ const getRhythmRoundRemainingMs = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearRhythmRoundCompletionTimer = () => {
|
||||||
|
if (rhythmRoundCompletionTimer !== null) {
|
||||||
|
clearTimeout(rhythmRoundCompletionTimer);
|
||||||
|
rhythmRoundCompletionTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 与当前显示的倒计时一起计时;消息校准不重建此任务,避免同一轮重复计数。
|
||||||
|
const scheduleRhythmRoundCompletion = (remainingMs) => {
|
||||||
|
if (
|
||||||
|
rhythmRoundCompletionTimer !== null ||
|
||||||
|
!props.start ||
|
||||||
|
!isRhythmTraining.value ||
|
||||||
|
remainingMs <= 0
|
||||||
|
) return;
|
||||||
|
|
||||||
|
rhythmRoundCompletionTimer = setTimeout(() => {
|
||||||
|
rhythmRoundCompletionTimer = null;
|
||||||
|
if (!props.start || !isRhythmTraining.value) return;
|
||||||
|
emit("rhythm-round-complete");
|
||||||
|
scheduleRhythmRoundCompletion(rhythmRoundDurationMs.value);
|
||||||
|
}, remainingMs);
|
||||||
|
};
|
||||||
|
|
||||||
const updateRhythmShootWindow = (isInWindow) => {
|
const updateRhythmShootWindow = (isInWindow) => {
|
||||||
const nextIsInWindow = Boolean(isInWindow);
|
const nextIsInWindow = Boolean(isInWindow);
|
||||||
const enteredShootWindow =
|
const enteredShootWindow =
|
||||||
@@ -364,7 +392,8 @@ const syncRhythmCountdown = async () => {
|
|||||||
const generation = ++rhythmSyncGeneration;
|
const generation = ++rhythmSyncGeneration;
|
||||||
clearRhythmTimers();
|
clearRhythmTimers();
|
||||||
|
|
||||||
if (!isRhythmTraining.value) {
|
if (!isRhythmTraining.value || !props.start) {
|
||||||
|
clearRhythmRoundCompletionTimer();
|
||||||
rhythmTransitionStyle.value = "none";
|
rhythmTransitionStyle.value = "none";
|
||||||
rhythmRemainingMs.value = 0;
|
rhythmRemainingMs.value = 0;
|
||||||
rhythmRemainingSeconds.value = 0;
|
rhythmRemainingSeconds.value = 0;
|
||||||
@@ -382,6 +411,7 @@ const syncRhythmCountdown = async () => {
|
|||||||
!validRhythmShootTime.value ||
|
!validRhythmShootTime.value ||
|
||||||
!normalizeTimestamp(props.shootWindowStart)
|
!normalizeTimestamp(props.shootWindowStart)
|
||||||
) {
|
) {
|
||||||
|
clearRhythmRoundCompletionTimer();
|
||||||
rhythmTransitionStyle.value = "none";
|
rhythmTransitionStyle.value = "none";
|
||||||
rhythmRemainingMs.value = 0;
|
rhythmRemainingMs.value = 0;
|
||||||
rhythmRemainingSeconds.value = 0;
|
rhythmRemainingSeconds.value = 0;
|
||||||
@@ -391,6 +421,7 @@ const syncRhythmCountdown = async () => {
|
|||||||
|
|
||||||
rhythmTransitionStyle.value = "none";
|
rhythmTransitionStyle.value = "none";
|
||||||
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
|
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
|
||||||
|
scheduleRhythmRoundCompletion(rhythmRemainingMs.value);
|
||||||
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
|
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
|
||||||
updateRhythmShootWindow(
|
updateRhythmShootWindow(
|
||||||
rhythmRemainingMs.value > 0 &&
|
rhythmRemainingMs.value > 0 &&
|
||||||
@@ -462,6 +493,7 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [
|
() => [
|
||||||
|
props.start,
|
||||||
props.trainingType,
|
props.trainingType,
|
||||||
props.roundTime,
|
props.roundTime,
|
||||||
props.shootTime,
|
props.shootTime,
|
||||||
@@ -493,7 +525,7 @@ async function onReceiveMessage(msg) {
|
|||||||
audioManager.play("比赛开始");
|
audioManager.play("比赛开始");
|
||||||
}
|
}
|
||||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||||
audioManager.play("练习结束", false);
|
audioManager.play(getTrainingEndAudioKey(msg.completed), false);
|
||||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||||
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
||||||
if (props.externalShootResultAudio) return;
|
if (props.externalShootResultAudio) return;
|
||||||
@@ -550,6 +582,7 @@ onBeforeUnmount(() => {
|
|||||||
clearTimer();
|
clearTimer();
|
||||||
rhythmSyncGeneration += 1;
|
rhythmSyncGeneration += 1;
|
||||||
clearRhythmTimers();
|
clearRhythmTimers();
|
||||||
|
clearRhythmRoundCompletionTimer();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const previewLines = computed(() => {
|
|||||||
: [{ text: line }];
|
: [{ text: line }];
|
||||||
const parts = rawParts
|
const parts = rawParts
|
||||||
.map((part) => ({
|
.map((part) => ({
|
||||||
text: String(part?.text ?? part ?? "").trim(),
|
text: String(part?.text ?? part ?? ""),
|
||||||
|
highlight: part?.highlight === true,
|
||||||
}))
|
}))
|
||||||
.filter((part) => part.text);
|
.filter((part) => part.text);
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ const previewLines = computed(() => {
|
|||||||
<text
|
<text
|
||||||
v-for="(part, partIndex) in line.parts"
|
v-for="(part, partIndex) in line.parts"
|
||||||
:key="partIndex"
|
:key="partIndex"
|
||||||
|
:class="{ 'difficulty-preview__highlight': part.highlight }"
|
||||||
>{{ part.text }}</text>
|
>{{ part.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -89,7 +91,7 @@ const previewLines = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-preview__copy {
|
.difficulty-preview__copy {
|
||||||
width: 84%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: block;
|
display: block;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -102,4 +104,9 @@ const previewLines = computed(() => {
|
|||||||
.difficulty-preview__line {
|
.difficulty-preview__line {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.difficulty-preview__highlight {
|
||||||
|
color: #ffd543;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -110,50 +110,51 @@ const createDifficultySummary = (item = {}) => {
|
|||||||
const timeLimit = toNumber(item.time_limit);
|
const timeLimit = toNumber(item.time_limit);
|
||||||
const hitReq = toNumber(item.hit_req);
|
const hitReq = toNumber(item.hit_req);
|
||||||
const totalReq = toNumber(item.total_req);
|
const totalReq = toNumber(item.total_req);
|
||||||
const energyReqPercent = toNumber(item.energy_req_percent);
|
const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows;
|
||||||
const shootingTimeText =
|
const timeParts = timeLimit > 0
|
||||||
timeLimit > 0 ? `在${timeLimit}秒内进行射箭` : "不限时进行射箭";
|
? [{ text: `${timeLimit}`, highlight: true }, { text: "秒内," }]
|
||||||
const enduranceTimeText =
|
: [{ text: "不限时," }];
|
||||||
timeLimit > 0
|
|
||||||
? `在${timeLimit}秒内完成${arrows}箭`
|
|
||||||
: `不限时完成${arrows}箭`;
|
|
||||||
|
|
||||||
const summaryMap = {
|
const summaryMap = {
|
||||||
base: [
|
base: [
|
||||||
shootingTimeText,
|
{ parts: [
|
||||||
`需要有${arrows}箭命中${hitReq}环内`,
|
...timeParts,
|
||||||
|
{ text: "命中" }, { text: `${arrows}`, highlight: true },
|
||||||
|
{ text: "支" }, { text: `≥${hitReq}`, highlight: true },
|
||||||
|
{ text: "环的箭" },
|
||||||
|
] },
|
||||||
],
|
],
|
||||||
endurance: [
|
endurance: [
|
||||||
enduranceTimeText,
|
{ parts: [
|
||||||
`且累计环数大于${totalReq}环`,
|
...timeParts,
|
||||||
|
{ text: "共射出" }, { text: `≥${arrows}`, highlight: true },
|
||||||
|
{ text: "支箭,且总环数" }, { text: `≥${totalReq}`, highlight: true },
|
||||||
|
{ text: "环" },
|
||||||
|
] },
|
||||||
],
|
],
|
||||||
precision: [
|
precision: [
|
||||||
shootingTimeText,
|
{ parts: [
|
||||||
`需要有${arrows}箭命中高亮区域`,
|
...timeParts,
|
||||||
|
{ text: "命中" }, { text: `${arrows}`, highlight: true },
|
||||||
|
{ text: "箭高亮区域" },
|
||||||
|
] },
|
||||||
],
|
],
|
||||||
rhythm: [
|
rhythm: [
|
||||||
arrows > 0
|
{ parts: [
|
||||||
? `需要在指定时间节点射出${arrows}射箭`
|
{ text: `${arrows}`, highlight: true },
|
||||||
: "需要在指定时间节点射箭",
|
{ text: "个节奏内,随节奏射箭,命中" },
|
||||||
hitReq > 0 ? `且每箭命中${hitReq}环内` : "且每箭命中指定区域",
|
{ text: `${rhythmRequiredArrows}`, highlight: true },
|
||||||
|
{ text: "箭成绩" }, { text: `≥${hitReq}`, highlight: true },
|
||||||
|
{ text: "环且踩中节拍区间" },
|
||||||
|
] },
|
||||||
],
|
],
|
||||||
stability: [
|
stability: [
|
||||||
{
|
{
|
||||||
parts: [
|
parts: [
|
||||||
{ text: timeLimit > 0 ? "在" : "" },
|
...timeParts,
|
||||||
{
|
{ text: "每射一箭" },
|
||||||
text: timeLimit > 0 ? `${timeLimit}秒` : "不限时",
|
{ text: `≥${hitReq}`, highlight: true },
|
||||||
highlight: true,
|
{ text: "环可为能量槽充能,能量充满即过关" },
|
||||||
},
|
|
||||||
{ text: timeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
|
|
||||||
{ text: `${hitReq}环`, highlight: true },
|
|
||||||
{ text: "可获得能量" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parts: [
|
|
||||||
{ text: "计时结束能量需要大于" },
|
|
||||||
{ text: `${energyReqPercent}%`, highlight: true },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -628,6 +629,7 @@ const createPracticeQuery = (difficulty) => {
|
|||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
hitReq: toNumber(difficulty.hit_req),
|
hitReq: toNumber(difficulty.hit_req),
|
||||||
|
totalReq: toNumber(difficulty.total_req, toNumber(difficulty.arrows)),
|
||||||
mode: toNumber(difficulty.mode),
|
mode: toNumber(difficulty.mode),
|
||||||
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
||||||
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ const rhythmServerTimestamp = ref(0);
|
|||||||
const rhythmFallbackWindowStart = ref(0);
|
const rhythmFallbackWindowStart = ref(0);
|
||||||
const rhythmFallbackTimestamp = ref(0);
|
const rhythmFallbackTimestamp = ref(0);
|
||||||
const rhythmHasActiveServerAnchor = ref(false);
|
const rhythmHasActiveServerAnchor = ref(false);
|
||||||
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。
|
// 仅由前端每轮倒计时完成事件累加,不根据服务端箭数推算。
|
||||||
const rhythmTargetArrows = ref(0);
|
const rhythmCompletedRounds = ref(0);
|
||||||
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
||||||
const visiblePrecisionTarget = ref({
|
const visiblePrecisionTarget = ref({
|
||||||
randomBlock: 0,
|
randomBlock: 0,
|
||||||
@@ -440,10 +440,11 @@ const trainingCopy = computed(() => {
|
|||||||
return {
|
return {
|
||||||
inline: true,
|
inline: true,
|
||||||
details: [
|
details: [
|
||||||
{ text: "计时结束前需要有" },
|
{ text: "命中 " },
|
||||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
||||||
{ text: "箭命中" },
|
{ text: " 支" },
|
||||||
{ text: `${hitReq}环`, highlight: true },
|
{ text: `≥${hitReq}`, highlight: true },
|
||||||
|
{ text: "环的箭" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -463,11 +464,11 @@ const trainingCopy = computed(() => {
|
|||||||
return {
|
return {
|
||||||
inline: true,
|
inline: true,
|
||||||
details: [
|
details: [
|
||||||
{ text: "计时结束前完成" },
|
{ text: "射出≥ " },
|
||||||
{ text: `(${currentArrows}/${targetArrows})`, highlight: true },
|
{ text: `(${currentArrows}/${targetArrows})`, highlight: true },
|
||||||
{ text: "箭且累计命中" },
|
{ text: " 支箭,总环数达到≥ " },
|
||||||
{ text: `(${currentRings}/${targetRings})`, highlight: true },
|
{ text: `(${currentRings}/${targetRings})`, highlight: true },
|
||||||
{ text: "环" },
|
{ text: " 环" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -486,36 +487,34 @@ const trainingCopy = computed(() => {
|
|||||||
return {
|
return {
|
||||||
inline: true,
|
inline: true,
|
||||||
details: [
|
details: [
|
||||||
{ text: "射箭命中高亮区域需完成" },
|
{ text: "命中高亮区域 " },
|
||||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
||||||
{ text: "箭" },
|
{ text: " 箭" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trainingType.value === "rhythm") {
|
if (trainingType.value === "rhythm") {
|
||||||
const targetArrows = getPositiveInteger(rhythmTargetArrows.value);
|
// 总节奏数沿用关卡配置,与选择页保持一致,不使用消息中的箭数覆盖。
|
||||||
const arrowsLeft = Math.min(
|
const totalArrows = getPositiveInteger(total.value);
|
||||||
targetArrows,
|
// 节奏数由前端倒计时累计,达标箭数与下方金色箭记录使用同一判定。
|
||||||
Math.max(
|
const targetArrows =
|
||||||
0,
|
getPositiveInteger(practiceInfo.value.targetArrows) ||
|
||||||
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
|
getPositiveInteger(trainingParams.value.totalReq) || totalArrows;
|
||||||
)
|
// 展示当前节奏序号,从 1 开始;内部仍累计已完成次数。
|
||||||
);
|
const currentRound = Math.min(totalArrows, rhythmCompletedRounds.value + 1);
|
||||||
const completedArrows = targetArrows - arrowsLeft;
|
|
||||||
const hitReq = rhythmHitReq.value;
|
const hitReq = rhythmHitReq.value;
|
||||||
|
const qualifiedArrows = scores.value.filter((arrow) => arrow?.ok === true).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inline: true,
|
inline: true,
|
||||||
details: [
|
details: [
|
||||||
{ text: "在进度条读取到高亮区间时射箭命中" },
|
{ text: `(${currentRound}/${totalArrows})`, highlight: true },
|
||||||
{
|
{ text: " 个节奏内,跟随节奏射箭,命中 " },
|
||||||
text: hitReq > 0 ? `${hitReq}环内` : "指定区域",
|
{ text: `(${qualifiedArrows}/${targetArrows})`, highlight: true },
|
||||||
highlight: true,
|
{ text: " 箭" },
|
||||||
},
|
{ text: `≥${hitReq}`, highlight: true },
|
||||||
{ text: ",需完成" },
|
{ text: "环且踩中节拍区间" },
|
||||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
|
||||||
{ text: "箭" },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -525,26 +524,12 @@ const trainingCopy = computed(() => {
|
|||||||
practiceInfo.value.hitReq,
|
practiceInfo.value.hitReq,
|
||||||
trainingParams.value.hitReq
|
trainingParams.value.hitReq
|
||||||
);
|
);
|
||||||
const stabilityTimeLimit = getPracticeNumber(
|
|
||||||
practiceInfo.value.timeLimit,
|
|
||||||
trainingParams.value.timeLimit
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inline: true,
|
inline: true,
|
||||||
details: [
|
details: [
|
||||||
{ text: stabilityTimeLimit > 0 ? "在" : "" },
|
{ text: "每射一箭" },
|
||||||
{
|
{ text: `≥${hitReq}`, highlight: true },
|
||||||
text: stabilityTimeLimit > 0 ? `${stabilityTimeLimit}秒` : "不限时",
|
{ text: "环可为能量槽充能,充满即过关" },
|
||||||
highlight: true,
|
|
||||||
},
|
|
||||||
{ text: stabilityTimeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
|
|
||||||
{ text: `${hitReq}环`, highlight: true },
|
|
||||||
{ text: "可获得能量,计时结束能量需要大于" },
|
|
||||||
{
|
|
||||||
text: `${stabilityEnergyReqPercent.value}%`,
|
|
||||||
highlight: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -709,31 +694,14 @@ const cacheRhythmTrainingConfig = (message = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheRhythmTargetArrows = (message = {}) => {
|
const onRhythmRoundComplete = () => {
|
||||||
const messageTrainingType = String(
|
if (!isShootingStage.value || !start.value || !isRhythmTraining.value || practiceEnded.value) return;
|
||||||
message.trainingType ?? message.training_type ?? trainingType.value
|
const totalRounds = getPositiveInteger(total.value);
|
||||||
).trim();
|
rhythmCompletedRounds.value = Math.min(totalRounds, rhythmCompletedRounds.value + 1);
|
||||||
if (messageTrainingType !== "rhythm") return;
|
|
||||||
|
|
||||||
const totalArrows = getPositiveInteger(
|
|
||||||
message.totalArrows ?? message.total_arrows
|
|
||||||
);
|
|
||||||
if (totalArrows > 0) {
|
|
||||||
rhythmTargetArrows.value = totalArrows;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
|
|
||||||
if (rhythmTargetArrows.value === 0) {
|
|
||||||
rhythmTargetArrows.value = getPositiveInteger(
|
|
||||||
message.arrowsLeft ?? message.arrows_left
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncPracticeInfo = (message = {}) => {
|
const syncPracticeInfo = (message = {}) => {
|
||||||
cacheRhythmTrainingConfig(message);
|
cacheRhythmTrainingConfig(message);
|
||||||
cacheRhythmTargetArrows(message);
|
|
||||||
const messageTrainingType = String(
|
const messageTrainingType = String(
|
||||||
message.trainingType ?? message.training_type ?? trainingType.value
|
message.trainingType ?? message.training_type ?? trainingType.value
|
||||||
).trim();
|
).trim();
|
||||||
@@ -744,6 +712,22 @@ const syncPracticeInfo = (message = {}) => {
|
|||||||
return result;
|
return result;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
const isRhythmSnapshot =
|
||||||
|
messageTrainingType === "rhythm" &&
|
||||||
|
(message.type === undefined ||
|
||||||
|
[
|
||||||
|
MESSAGETYPESV2.BattleStart,
|
||||||
|
MESSAGETYPESV2.ShootResult,
|
||||||
|
MESSAGETYPESV2.BattleEnd,
|
||||||
|
].includes(message.type));
|
||||||
|
|
||||||
|
if (isRhythmSnapshot) {
|
||||||
|
// 达标箭数的 0 按 protobuf 默认值处理;剩余数缺失不能用于推算节奏进度。
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(nextInfo, "qualifiedArrows")) {
|
||||||
|
nextInfo.qualifiedArrows = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isStabilityEnergySnapshot =
|
const isStabilityEnergySnapshot =
|
||||||
messageTrainingType === "stability" &&
|
messageTrainingType === "stability" &&
|
||||||
(Object.prototype.hasOwnProperty.call(message, "currentEnergy") ||
|
(Object.prototype.hasOwnProperty.call(message, "currentEnergy") ||
|
||||||
@@ -1422,7 +1406,7 @@ onLoad((options = {}) => {
|
|||||||
serverAddr.value = trainingContext.serverAddr || "";
|
serverAddr.value = trainingContext.serverAddr || "";
|
||||||
|
|
||||||
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
|
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
|
||||||
const warmupKeys = [startAudioKey, "胜利", "失败"];
|
const warmupKeys = [startAudioKey, "通关成功", "通关失败", "胜利", "失败"];
|
||||||
if (trainingParams.value.type === "precision") {
|
if (trainingParams.value.type === "precision") {
|
||||||
warmupKeys.push("Bingo命中目标", "未命中");
|
warmupKeys.push("Bingo命中目标", "未命中");
|
||||||
} else if (trainingParams.value.type === "rhythm") {
|
} else if (trainingParams.value.type === "rhythm") {
|
||||||
@@ -1452,6 +1436,7 @@ const onReady = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
practiceStarting.value = true;
|
practiceStarting.value = true;
|
||||||
|
rhythmCompletedRounds.value = 0;
|
||||||
pageStage.value = pageStages.LOADING;
|
pageStage.value = pageStages.LOADING;
|
||||||
clearHighlightTestTimer();
|
clearHighlightTestTimer();
|
||||||
useHighlightTest.value = false;
|
useHighlightTest.value = false;
|
||||||
@@ -1693,7 +1678,7 @@ async function onRetry() {
|
|||||||
rhythmFallbackWindowStart.value = 0;
|
rhythmFallbackWindowStart.value = 0;
|
||||||
rhythmFallbackTimestamp.value = 0;
|
rhythmFallbackTimestamp.value = 0;
|
||||||
rhythmHasActiveServerAnchor.value = false;
|
rhythmHasActiveServerAnchor.value = false;
|
||||||
rhythmTargetArrows.value = 0;
|
rhythmCompletedRounds.value = 0;
|
||||||
invalidateShotPresentations({ resetVisible: true });
|
invalidateShotPresentations({ resetVisible: true });
|
||||||
start.value = false;
|
start.value = false;
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
@@ -1716,7 +1701,7 @@ function onAudioEnded(key) {
|
|||||||
Array.from(audioWaiters).forEach((waiter) => {
|
Array.from(audioWaiters).forEach((waiter) => {
|
||||||
if (waiter.expectedKey === key) waiter.done();
|
if (waiter.expectedKey === key) waiter.done();
|
||||||
});
|
});
|
||||||
if (["比赛结束", "练习结束"].includes(String(key || ""))) {
|
if (["比赛结束", "练习结束", "通关成功", "通关失败"].includes(String(key || ""))) {
|
||||||
void onOver();
|
void onOver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1864,6 +1849,7 @@ onBeforeUnmount(() => {
|
|||||||
trainingType === 'stability'
|
trainingType === 'stability'
|
||||||
"
|
"
|
||||||
:onStop="onTimeLimitReached"
|
:onStop="onTimeLimitReached"
|
||||||
|
@rhythm-round-complete="onRhythmRoundComplete"
|
||||||
/>
|
/>
|
||||||
<view class="user-row">
|
<view class="user-row">
|
||||||
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
||||||
@@ -2139,7 +2125,7 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 40rpx;
|
line-height: 40rpx;
|
||||||
white-space: normal;
|
white-space: pre-line;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.text1{
|
.text1{
|
||||||
@@ -2155,9 +2141,7 @@ onBeforeUnmount(() => {
|
|||||||
line-height: 36rpx;
|
line-height: 36rpx;
|
||||||
}
|
}
|
||||||
.text2-yellow{
|
.text2-yellow{
|
||||||
font-size: 30rpx;
|
|
||||||
color: #FFD947;
|
color: #FFD947;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0 4rpx;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import { getSelectedExperienceStore, hasAnyStoreVip } from "@/utils/storeVip";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import UserHeader from "@/components/UserHeader.vue";
|
import UserHeader from "@/components/UserHeader.vue";
|
||||||
import UserItem from "@/components/UserItem.vue";
|
import UserItem from "@/components/UserItem.vue";
|
||||||
@@ -26,20 +28,40 @@ const toFristTryPage = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const toCoinPage = () => {
|
const toCoinPage = () => {
|
||||||
|
const selected = getSelectedExperienceStore();
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/coin/nearby-stores",
|
url: selected ? "/pages/coin/index?tab=vip" : "/pages/coin/nearby-stores?tab=vip",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const showCoinEntry = ref(false);
|
const showCoinEntry = ref(false);
|
||||||
|
const storeVipLabel = ref("加载中");
|
||||||
|
const storeVipFailed = ref(false);
|
||||||
|
let entryRequest = 0;
|
||||||
const loadCoinEntryVisibility = async () => {
|
const loadCoinEntryVisibility = async () => {
|
||||||
|
const requestId = ++entryRequest;
|
||||||
showCoinEntry.value = false;
|
showCoinEntry.value = false;
|
||||||
|
storeVipFailed.value = false;
|
||||||
|
storeVipLabel.value = "加载中";
|
||||||
try {
|
try {
|
||||||
const result = await getMyGoldAPI();
|
const result = await getMyGoldAPI();
|
||||||
|
if (requestId !== entryRequest) return;
|
||||||
showCoinEntry.value = result?.hasTempBind === true;
|
showCoinEntry.value = result?.hasTempBind === true;
|
||||||
|
if (!showCoinEntry.value) return;
|
||||||
|
try {
|
||||||
|
const active = await hasAnyStoreVip();
|
||||||
|
if (requestId !== entryRequest) return;
|
||||||
|
storeVipLabel.value = active ? "VIP已开通" : "VIP未开通";
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== entryRequest) return;
|
||||||
|
storeVipLabel.value = "加载失败,重试";
|
||||||
|
storeVipFailed.value = true;
|
||||||
|
console.error("加载入口门店VIP失败", error);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载金币入口状态失败", error);
|
console.error("加载金币入口状态失败", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
onShow(loadCoinEntryVisibility);
|
||||||
const toBeVipPage = () => {
|
const toBeVipPage = () => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/member/be-vip",
|
url: "/pages/member/be-vip",
|
||||||
@@ -85,7 +107,6 @@ const logout = () => {
|
|||||||
updateUser();
|
updateUser();
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCoinEntryVisibility();
|
|
||||||
const accountInfo = uni.getAccountInfoSync();
|
const accountInfo = uni.getAccountInfoSync();
|
||||||
const envVersion = accountInfo.miniProgram.envVersion;
|
const envVersion = accountInfo.miniProgram.envVersion;
|
||||||
if (envVersion !== "release") showLogout.value = true;
|
if (envVersion !== "release") showLogout.value = true;
|
||||||
@@ -127,10 +148,12 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
|||||||
</UserItem>
|
</UserItem>
|
||||||
<UserItem
|
<UserItem
|
||||||
v-if="showCoinEntry"
|
v-if="showCoinEntry"
|
||||||
title="我的金币"
|
title="线下体验中心"
|
||||||
:onClick="toCoinPage"
|
:onClick="toCoinPage"
|
||||||
/>
|
>
|
||||||
<UserItem title="会员" :onClick="toBeVipPage">
|
<text class="store-vip-label" @click.stop="storeVipFailed ? loadCoinEntryVisibility() : toCoinPage()">{{ storeVipLabel }}</text>
|
||||||
|
</UserItem>
|
||||||
|
<!-- <UserItem title="会员" :onClick="toBeVipPage">
|
||||||
<view
|
<view
|
||||||
v-if="user.sVip === true"
|
v-if="user.sVip === true"
|
||||||
class="member-nickname member-nickname--svip"
|
class="member-nickname member-nickname--svip"
|
||||||
@@ -145,7 +168,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
|||||||
<text class="member-nickname__text">已开通VIP</text>
|
<text class="member-nickname__text">已开通VIP</text>
|
||||||
</view>
|
</view>
|
||||||
<text v-else>未开通</text>
|
<text v-else>未开通</text>
|
||||||
</UserItem>
|
</UserItem> -->
|
||||||
<UserItem title="等级介绍" :onClick="toGradeIntroPage">
|
<UserItem title="等级介绍" :onClick="toGradeIntroPage">
|
||||||
<text :style="{ color: '#4C76FF' }">Lv{{ user.lvl }}</text>
|
<text :style="{ color: '#4C76FF' }">Lv{{ user.lvl }}</text>
|
||||||
<text
|
<text
|
||||||
@@ -205,6 +228,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.store-vip-label { padding: 4rpx 12rpx; border: 1rpx solid #999999; border-radius: 20rpx; font-size: 24rpx; }
|
||||||
.container {
|
.container {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
height: calc(100% - 65px);
|
height: calc(100% - 65px);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
import { getStoreBenefitAPI, getStoreVipListAPI } from "@/apis";
|
||||||
|
import useStore from "@/store";
|
||||||
|
|
||||||
|
// 按账号隔离最近选择,刷新登录令牌后仍可恢复同一门店。
|
||||||
|
const selectionKey = () => {
|
||||||
|
const version = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
|
return `${version}_experience_store_${useStore().user.id || "guest"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelectedExperienceStore = () => {
|
||||||
|
const value = uni.getStorageSync(selectionKey());
|
||||||
|
return value && /^\d+$/.test(String(value.id)) && Number(value.id) > 0
|
||||||
|
? { id: String(value.id), name: String(value.name || "") }
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rememberExperienceStore = (id, name) => {
|
||||||
|
uni.setStorageSync(selectionKey(), { id: String(id), name });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 入口汇总所有门店;找到有效会员即可结束,未开通须查询完整列表。
|
||||||
|
export const hasAnyStoreVip = async () => {
|
||||||
|
const pageSize = 20;
|
||||||
|
let received = 0;
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const result = await getStoreVipListAPI({ page, pageSize });
|
||||||
|
const list = result?.list;
|
||||||
|
if (!Array.isArray(list) || !Number.isSafeInteger(result.total) || result.total < 0) {
|
||||||
|
throw new Error("门店会员列表响应格式异常");
|
||||||
|
}
|
||||||
|
if (list.some((item) => item?.isVip === true)) return true;
|
||||||
|
if (list.some((item) => typeof item?.isVip !== "boolean")) {
|
||||||
|
throw new Error("门店会员状态缺失");
|
||||||
|
}
|
||||||
|
received += list.length;
|
||||||
|
if (received >= result.total) return false;
|
||||||
|
// 列表尚未读完却返回空页时,不把不完整结果判为未开通。
|
||||||
|
if (!list.length) throw new Error("门店会员列表分页异常");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只接受非负整数,缺失或异常值不能转换成“已用完”的 0 次。
|
||||||
|
const normalizeRemaining = (benefit) => {
|
||||||
|
const remain = benefit?.remain;
|
||||||
|
return Number.isSafeInteger(remain) && remain >= 0 ? remain : "--";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeStoreMembership = (store) => {
|
||||||
|
if (typeof store?.isVip !== "boolean") throw new Error("门店会员状态缺失");
|
||||||
|
const active = store.isVip === true;
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
cardImage: `https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-card-${active ? "active" : "inactive"}.png`,
|
||||||
|
expiresAt: active ? String(store.vipExpireAt || "").slice(0, 10).replace(/-/g, "/") || "--" : "",
|
||||||
|
// 未开通或已过期统一为 0;有效会员逐项读取后端剩余次数。
|
||||||
|
remaining: {
|
||||||
|
trial: active ? normalizeRemaining(store.freeTrial) : 0,
|
||||||
|
discount: active ? normalizeRemaining(store.discountPurchase) : 0,
|
||||||
|
drink: active ? normalizeRemaining(store.drink) : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadStoreMembership = async (storeId) => {
|
||||||
|
const result = await getStoreBenefitAPI(storeId);
|
||||||
|
if (String(result?.storeId) !== String(storeId)) {
|
||||||
|
throw new Error("门店权益响应与当前门店不匹配");
|
||||||
|
}
|
||||||
|
return { name: result.storeName || "", ...normalizeStoreMembership(result) };
|
||||||
|
};
|
||||||