31 Commits
Author SHA1 Message Date
zhangyi 9699f1356f Merge branch 'feat-shebei' into test 2026-10-29 16:49:26 +08:00
zhangyi 899a222e3f update:优化 2026-10-29 16:49:08 +08:00
zhangyi ca727ec1f7 update:优化wifi反馈 2026-10-29 16:28:03 +08:00
zhangyi aa33a931b1 Merge branch 'feat-shebei' into test 2026-10-29 16:12:32 +08:00
zhangyi bac84536c7 update:优化样式 2026-10-29 16:11:58 +08:00
zhangyi f044a95872 Merge branch 'feat-shebei' into test 2026-10-29 15:08:06 +08:00
zhangyi 94c46bb7df update:隐藏新人礼 2026-10-29 15:07:47 +08:00
zhangyi 318b952a9b Merge branch 'feat-training' into test 2026-10-23 14:50:28 +08:00
zhangyi 05f73acf47 update:优化节奏训练计数 2026-10-23 14:50:15 +08:00
zhangyi 14437bb5a7 Merge branch 'feat-training' into test 2026-10-23 11:55:19 +08:00
zhangyi eca15178f4 update:优化语音和训练文案 2026-10-23 11:55:02 +08:00
zhangyi f16b486a89 update:优化打包 2026-10-21 16:02:34 +08:00
zhangyi 6c69e14630 Merge branch 'feat-mendian' into test 2026-10-21 15:50:01 +08:00
zhangyi 9eb6ef7cf1 update:优化图片地址 2026-10-21 15:49:43 +08:00
zhangyi 34118d59d3 Merge branch 'feat-mendian' into test 2026-10-21 15:38:46 +08:00
zhangyi 00f84fc3e4 update:新增门店vip相关 2026-10-21 15:27:31 +08:00
zhangyi b82474913a Merge branch 'feat-training' into test 2026-10-13 11:33:10 +08:00
zhangyi 5bb9df8a03 update:优化节奏训练文案 2026-10-12 16:12:24 +08:00
zhangyi 9d9f005ca3 Merge branch 'feat-training' into test 2026-10-12 14:57:28 +08:00
zhangyi bad336c7d4 update:更新训练的说明文案 2026-10-12 14:57:11 +08:00
zhangyi 605a0b2d91 Merge branch 'feat-shebei' into test 2026-10-08 14:35:34 +08:00
zhangyi cee09cd7fa update:隐藏会员入口 2026-10-08 14:34:09 +08:00
zhangyi c9bfa6792b Merge branch 'feat-shebei' into test 2026-09-24 17:10:53 +08:00
zhangyi ae3ba60191 update:优化 2026-09-24 17:10:10 +08:00
zhangyi b0ddf147aa Merge branch 'feat-shebei' into test 2026-09-24 15:16:37 +08:00
zhangyi f4b37fba00 update:优化ota 2026-09-24 15:16:14 +08:00
zhangyi f8bf9fdb63 update:新增扫码绑定失败界面 2026-09-24 14:23:42 +08:00
zhangyi 3caa53d4be Merge branch 'feat-training' into test 2026-09-24 10:23:55 +08:00
zhangyi 21ed8066aa update:优化稳定训练提示 2026-09-24 10:23:38 +08:00
zhangyi f4f63954eb Merge branch 'feat-shebei' into test 2026-09-23 18:35:04 +08:00
zhangyi 78fd64c697 update:优化wifi 2026-09-23 18:33:12 +08:00
42 changed files with 1806 additions and 901 deletions
+4
View File
@@ -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"
+26 -5
View File
@@ -592,14 +592,20 @@ export const connectDeviceWifiAPI = async (ssid, password) => {
); );
}; };
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。 // 查询设备当前联网类型及已连接的 WiFi 名称。
export const getHardwareBoxVersionAPI = async () => { export const getDeviceWifiStatusAPI = async () => {
return request("GET", "/user/hardwareBox/version"); return request("GET", "/user/device/wifiStatus", {}, BASE_URL, [0], {
showErrorToast: false,
});
}; };
// 发送硬件盒子 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) => {
@@ -764,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,
+8
View File
@@ -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",
命中: 命中:
+6 -2
View File
@@ -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({
+17 -3
View File
@@ -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) {
+4
View File
@@ -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"
+5
View File
@@ -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);
+6
View File
@@ -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 -4
View File
@@ -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>
+135
View File
@@ -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的介绍 &gt;</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>
+28
View File
@@ -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",
},
];
+134 -16
View File
@@ -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;
+9 -2
View File
@@ -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>
+48
View File
@@ -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: "",
}; };
+154
View File
@@ -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>
+52 -117
View File
@@ -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>没找到二维码或遇到问题&gt;</text> <text>没找到二维码或遇到问题&gt;</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%;
+405 -292
View File
@@ -1,26 +1,35 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onLoad, onShow } from "@dcloudio/uni-app";
import { storeToRefs } from "pinia";
import Container from "@/components/Container.vue"; import Container from "@/components/Container.vue";
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 useStore from "@/store";
import { capsuleHeight } from "@/util";
import { import {
connectDeviceWifiAPI, connectDeviceWifiAPI,
getHardwareBoxVersionAPI, getDeviceDetailAPI,
getDeviceWifiStatusAPI,
} from "@/apis"; } from "@/apis";
import { useOtaUpdate } from "@/composables/useOtaUpdate"; import { getOtaInfoFromDetail, useOtaUpdate } from "@/composables/useOtaUpdate";
const STATES = { const STATES = {
SCANNING: "SCANNING", SCANNING: "SCANNING",
LIST: "LIST", LIST: "LIST",
CONNECTING: "CONNECTING", CONNECTING: "CONNECTING",
CONNECTED: "CONNECTED", CONNECTED: "CONNECTED",
LOCATION_REQUIRED: "LOCATION_REQUIRED",
}; };
const isIOS = uni.getDeviceInfo().osName === "ios"; const isIOS = uni.getDeviceInfo().osName === "ios";
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 isDeviceOnWifi = computed(
() => deviceStatus.value.online === true && deviceStatus.value.netType === "wifi"
);
const wifiList = ref([]); const wifiList = ref([]);
const showWifiBanner = ref(false); const showWifiBanner = ref(false);
@@ -30,25 +39,31 @@ 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);
const firmwareMessage = ref(""); const firmwareMessage = ref("");
let countdownTimer = null; let countdownTimer = null;
let wifiConnectRequestId = 0; let wifiConnectRequestId = 0;
let wifiStatusRequestId = 0;
const WIFI_CONNECT_FAILED_TEXT = "连接失败,请检查WiFi密码或WiFi状态"; const WIFI_CONNECT_FAILED_TEXT = "连接失败,请检查WiFi密码或WiFi状态";
// 控制授权拒绝弹窗显示/隐藏 // 与 Container 的可滚动区域保持一致,底部说明在长短屏上都能贴近安全区。
const wifiAuthDeniedVisible = ref(false); const windowInfo = uni.getWindowInfo();
const contentMinHeight = Math.max(
0,
Math.round(
(windowInfo.windowHeight - capsuleHeight - 50) *
(750 / windowInfo.windowWidth)
)
);
const { const {
updating: otaUpdating, updating: otaUpdating,
@@ -73,20 +88,29 @@ const isWifiPermissionDenied = (err) => {
return /auth den|authorize|permission denied|user denied|scope\.userLocation/i.test(errMsg); return /auth den|authorize|permission denied|user denied|scope\.userLocation/i.test(errMsg);
}; };
// 显示授权拒绝弹窗,通过 ScreenHint 组件呈现。 // Android 定位不可用时展示蓝湖中的独立引导状态。
const showWifiPermissionDeniedModal = () => { const showLocationPermission = () => {
wifiAuthDeniedVisible.value = true; currentState.value = STATES.LOCATION_REQUIRED;
isRefreshing.value = false;
}; };
// 用户点击「重新授权」时,打开小程序权限设置页,引导用户开启位置权限后重新扫描。 // 已拒绝授权时进入小程序设置;首次授权则重新扫描以触发系统权限流程。
const handleReauthorize = () => { const handleReauthorize = () => {
wifiAuthDeniedVisible.value = false; if (uni.getSystemSetting().locationEnabled === false) {
uni.openSetting({ uni.showToast({ title: "请先开启手机定位服务", icon: "none" });
success(res) { return;
// 若用户在设置页开启了位置权限,则重新启动 WiFi 扫描
if (res.authSetting["scope.userLocation"]) {
startScanning();
} }
uni.getSetting({
success(res) {
if (res.authSetting["scope.userLocation"] !== false) {
startScanning();
return;
}
uni.openSetting({
success(setting) {
if (setting.authSetting["scope.userLocation"]) startScanning();
},
});
}, },
}); });
}; };
@@ -96,7 +120,7 @@ const applyIOSConnectedWifi = (wifi) => {
const ssid = wifi?.SSID; const ssid = wifi?.SSID;
if (!ssid) { if (!ssid) {
wifiList.value = []; wifiList.value = [];
currentState.value = STATES.LIST; currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
isRefreshing.value = false; isRefreshing.value = false;
return; return;
} }
@@ -109,7 +133,7 @@ const applyIOSConnectedWifi = (wifi) => {
fromCurrentWifi: true, fromCurrentWifi: true,
}, },
]; ];
currentState.value = STATES.LIST; currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
isRefreshing.value = false; isRefreshing.value = false;
}; };
@@ -127,7 +151,7 @@ const syncIOSConnectedWifi = () => {
err, err,
}); });
wifiList.value = []; wifiList.value = [];
currentState.value = STATES.LIST; currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
isRefreshing.value = false; isRefreshing.value = false;
}, },
}); });
@@ -147,7 +171,7 @@ const startIOSScanning = () => {
err, err,
}); });
if (err.errCode === 12005) showWifiBanner.value = true; if (err.errCode === 12005) showWifiBanner.value = true;
currentState.value = STATES.LIST; currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
isRefreshing.value = false; isRefreshing.value = false;
}, },
}); });
@@ -168,6 +192,11 @@ const startScanning = () => {
return; return;
} }
if (uni.getSystemSetting().locationEnabled === false) {
showLocationPermission();
return;
}
wx.startWifi({ wx.startWifi({
success(res) { success(res) {
console.log("[OTA WiFi] wx.startWifi success:", res); console.log("[OTA WiFi] wx.startWifi success:", res);
@@ -181,7 +210,11 @@ const startScanning = () => {
errMsg: err?.errMsg, errMsg: err?.errMsg,
err, err,
}); });
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal(); if (isWifiPermissionDenied(err)) {
showLocationPermission();
} else {
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
}
if (err.errCode === 12005) showWifiBanner.value = true; if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false; isRefreshing.value = false;
}, },
@@ -193,13 +226,18 @@ const startScanning = () => {
errMsg: err?.errMsg, errMsg: err?.errMsg,
err, err,
}); });
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal(); if (isWifiPermissionDenied(err)) {
showLocationPermission();
} else {
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
}
if (err.errCode === 12005) showWifiBanner.value = true; if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false; isRefreshing.value = false;
}, },
}); });
wx.onGetWifiList((res) => { wx.onGetWifiList((res) => {
if (currentState.value === STATES.LOCATION_REQUIRED) return;
const realWifiList = res.wifiList || []; const realWifiList = res.wifiList || [];
if (realWifiList.length) { if (realWifiList.length) {
console.log("[OTA WiFi] wx.onGetWifiList received wifiList:", realWifiList); console.log("[OTA WiFi] wx.onGetWifiList received wifiList:", realWifiList);
@@ -207,7 +245,7 @@ const startScanning = () => {
console.warn("[OTA WiFi] wx.onGetWifiList received empty wifiList:", res); console.warn("[OTA WiFi] wx.onGetWifiList received empty wifiList:", res);
} }
wifiList.value = realWifiList; wifiList.value = realWifiList;
currentState.value = STATES.LIST; currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
isRefreshing.value = false; isRefreshing.value = false;
}); });
}; };
@@ -256,12 +294,31 @@ const joinDisabled = computed(() => {
return false; return false;
}); });
// 按当前页面状态限制 WiFi 列表高度,避免列表撑开整页。 // 列表按实际条目收起;空间不足时由页面布局限制高度并在卡片内滚动。
const wifiListScrollHeight = computed(() => { const wifiListScrollHeight = computed(() => {
if (currentState.value === STATES.SCANNING) return "auto";
const itemCount = wifiList.value.length + 1; const itemCount = wifiList.value.length + 1;
const maxHeight = currentState.value === STATES.CONNECTED ? 276 : 420; return `${itemCount * 108}rpx`;
return `${Math.min(itemCount * 92, maxHeight)}rpx`; });
// 展示条件只取 WebSocket 设备状态;接口仅提供名称,过期请求不得覆盖新状态。
const refreshConnectedWifiName = async () => {
const requestId = ++wifiStatusRequestId;
if (!isDeviceOnWifi.value) {
connectedWifiName.value = "";
return;
}
connectedWifiName.value = "";
try {
const status = await getDeviceWifiStatusAPI();
if (requestId !== wifiStatusRequestId || !isDeviceOnWifi.value) return;
connectedWifiName.value = String(status?.ssid ?? "").trim();
} catch (error) {
if (requestId === wifiStatusRequestId) connectedWifiName.value = "";
}
};
watch(isDeviceOnWifi, () => {
void refreshConnectedWifiName();
}); });
// 取消当前 WiFi 连接请求的页面等待状态,并忽略可能迟到的响应。 // 取消当前 WiFi 连接请求的页面等待状态,并忽略可能迟到的响应。
@@ -308,9 +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();
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) {
@@ -325,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;
@@ -335,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 = () => {
@@ -388,7 +457,9 @@ const openFirmwareCountdown = () => {
// 监听系统输入法高度,用于让底部弹窗避开键盘遮挡。 // 监听系统输入法高度,用于让底部弹窗避开键盘遮挡。
const handleKeyboardHeightChange = (res) => { const handleKeyboardHeightChange = (res) => {
keyboardHeight.value = res?.height || 0; keyboardHeight.value = Math.round(
(res?.height || 0) * (750 / windowInfo.windowWidth)
);
}; };
// 切换密码显示状态前先收起键盘,避免安全键盘和普通输入法切换时复用旧高度产生黑条。 // 切换密码显示状态前先收起键盘,避免安全键盘和普通输入法切换时复用旧高度产生黑条。
@@ -404,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 扫描流程。
@@ -420,12 +488,24 @@ onMounted(() => {
// iOS 从系统 WiFi 设置返回小程序后,同步当前手机连接的 WiFi。 // iOS 从系统 WiFi 设置返回小程序后,同步当前手机连接的 WiFi。
onShow(() => { onShow(() => {
void refreshConnectedWifiName();
if (isIOS && currentState.value === STATES.LIST) { if (isIOS && currentState.value === STATES.LIST) {
syncIOSConnectedWifi(); syncIOSConnectedWifi();
} else if (
!isIOS &&
currentState.value === STATES.LOCATION_REQUIRED &&
uni.getSystemSetting().locationEnabled !== false
) {
uni.getSetting({
success(res) {
if (res.authSetting["scope.userLocation"] !== false) startScanning();
},
});
} }
}); });
onUnmounted(() => { onUnmounted(() => {
wifiStatusRequestId += 1;
if (typeof uni.offKeyboardHeightChange === "function") { if (typeof uni.offKeyboardHeightChange === "function") {
uni.offKeyboardHeightChange(handleKeyboardHeightChange); uni.offKeyboardHeightChange(handleKeyboardHeightChange);
} }
@@ -436,79 +516,93 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<Container title="连接无线网络"> <Container :scroll="false">
<!-- WiFi 不可用 Banner --> <!-- WiFi 不可用 Banner -->
<view v-if="showWifiBanner" class="wifi-banner"> <view v-if="showWifiBanner" class="wifi-banner">
<text class="wifi-banner-text">请先开启 WiFi 后刷新重试</text> <text class="wifi-banner-text">请先开启 WiFi 后刷新重试</text>
</view> </view>
<!-- SCANNING / LIST / CONNECTED --> <!-- 扫描、候选网络与设备已连接网络共用页面主体 -->
<view <view
v-if="currentState === 'SCANNING' || currentState === 'LIST' || currentState === 'CONNECTED'" v-if="currentState === 'SCANNING' || currentState === 'LIST' || currentState === 'CONNECTED'"
class="wifi-page" class="wifi-page"
:style="{ height: contentMinHeight + 'rpx' }"
> >
<!-- Hero: wifi1.png 单图(已内含吉祥物) -->
<view class="hero-area"> <view class="hero-area">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/wifi1.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
</view>
<text class="page-title" :class="{ 'connected-page-title': currentState === 'CONNECTED' }">
{{ currentState === 'CONNECTED' ? '连接成功' : '连接无线网络' }}
</text>
<text v-if="currentState !== 'CONNECTED'" class="page-subtitle">网络名称仅支持英文字符及数字</text>
<view v-if="isIOS && currentState !== 'CONNECTED'" class="ios-guide">
<text class="ios-guide-text">请先在系统 WiFi 设置中连接目标网络,返回后选择当前 WiFi 并填写密码</text>
</view>
<!-- 已连接网络卡片 -->
<view v-if="currentState === 'CONNECTED'" class="section-label-row connected-section-label-row">
<text class="section-label">连接网络</text>
</view>
<view v-if="currentState === 'CONNECTED'" class="wifi-list-card connected-wifi-card">
<view class="wifi-item connected-wifi-item">
<image class="check-icon" src="https://static.shelingxingqiu.com/shootmini/static/sicon/check.png" mode="aspectFit" />
<text class="wifi-ssid connected-wifi-ssid">{{ connectedWifi?.SSID }}</text>
<view class="wifi-icons connected-wifi-icons">
<image <image
v-if="connectedWifi?.secure" class="hero-image"
class="security-icon" src="https://static.shelingxingqiu.com/shootmini/static/ota/wifi1.png"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/pwd.png"
mode="aspectFit" mode="aspectFit"
/> />
<image class="signal-icon" src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png" mode="aspectFit" />
</view> </view>
<text class="page-title">智能弓箭连接无线网络</text>
<text class="page-subtitle">提醒:本操作将为智能弓箭连接WIFI网络,不会影响手机自身连接的WIFI网络。</text>
<view class="network-sections">
<!-- 仅设备 WebSocket 状态为 WiFi 时显示;此卡片不触发密码输入。 -->
<view v-if="isDeviceOnWifi && connectedWifiName" class="network-section connected-section">
<text class="section-label">已连接</text>
<view class="connected-wifi-card">
<text class="wifi-ssid">{{ connectedWifiName }}</text>
<image
class="check-icon"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/check.png"
mode="aspectFit"
/>
</view> </view>
</view> </view>
<!-- 网络列表标题 --> <view class="network-section">
<view class="section-label-row"> <view class="section-label-row">
<text class="section-label">网络</text> <text class="section-label">选择网络</text>
<image <image
class="refresh-icon"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/refresh.png" src="https://static.shelingxingqiu.com/shootmini/static/sicon/refresh.png"
mode="aspectFit" mode="aspectFit"
:style="{ width: '34rpx', height: '34rpx', marginLeft: '8rpx', opacity: isRefreshing ? 0.3 : 0.7 }" :style="{ opacity: isRefreshing ? 0.3 : 0.6 }"
@click="startScanning" @click="startScanning"
/> />
</view> </view>
<!-- 网络列表卡片 --> <block v-if="isIOS">
<scroll-view <view
scroll-y v-if="wifiList.length"
class="wifi-list-card" class="ios-network-card current-network-card"
:class="{ 'wifi-list-scroll': currentState !== 'SCANNING' }" @click="selectWifi(wifiList[0])"
:style="{ height: wifiListScrollHeight }"
> >
<block v-if="currentState === 'SCANNING'"> <view class="network-copy">
<view class="wifi-item" @click="selectOther"> <text class="wifi-ssid">{{ wifiList[0].SSID }}</text>
<text class="wifi-ssid other-text">其他...</text> <text class="network-desc">手机当前连接的网络</text>
</view>
<image
class="signal-icon"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
mode="aspectFit"
/>
</view>
<view class="ios-network-card manual-network-card" @click="selectOther">
<view class="network-copy">
<text class="wifi-ssid">手动连接其他网络</text>
<text class="network-desc">由于权限限制,无法获取附近网络列表,如需连接其他网络请手动输入网络名称与密码进行连接。</text>
</view>
<image
class="signal-icon"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
mode="aspectFit"
/>
</view> </view>
</block> </block>
<block v-else>
<scroll-view
v-else
scroll-y
class="android-list-card"
:style="{ maxHeight: wifiListScrollHeight }"
>
<view <view
v-for="(wifi, idx) in wifiList" v-for="wifi in wifiList"
:key="wifi.SSID" :key="wifi.SSID"
class="wifi-item" class="android-network-item"
@click="selectWifi(wifi)" @click="selectWifi(wifi)"
> >
<text class="wifi-ssid">{{ wifi.SSID }}</text> <text class="wifi-ssid">{{ wifi.SSID }}</text>
@@ -519,20 +613,46 @@ onUnmounted(() => {
src="https://static.shelingxingqiu.com/shootmini/static/sicon/pwd.png" src="https://static.shelingxingqiu.com/shootmini/static/sicon/pwd.png"
mode="aspectFit" mode="aspectFit"
/> />
<image class="signal-icon" src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png" mode="aspectFit" /> <image
class="signal-icon"
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
mode="aspectFit"
/>
</view> </view>
</view> </view>
<view class="wifi-item" @click="selectOther"> <view class="android-network-item manual-network-item" @click="selectOther">
<text class="wifi-ssid other-text">其他...</text> <text class="wifi-ssid">手动连接其他网络</text>
</view> </view>
</block>
</scroll-view> </scroll-view>
</view>
</view>
<text class="wifi-footer">智能弓箭目前仅支持保存一条网络信息,若连接新的网络将删除上一条网络信息。</text>
</view>
<!-- Android 定位权限不足时的整页引导状态 -->
<view
v-if="currentState === 'LOCATION_REQUIRED'"
class="location-page"
:style="{ minHeight: contentMinHeight + 'rpx' }"
>
<view class="location-image-wrap">
<image
class="location-image"
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/wifi-location.png"
mode="aspectFit"
/>
<view class="location-shadow"></view>
</view>
<text class="location-desc">请授权允许获取定位信息,并开启定位功能,以查找附近可用无线网络。</text>
<view class="location-button" @click="handleReauthorize">
<text>授权获取定位信息</text>
</view>
</view> </view>
<!-- CONNECTING 底部弹窗 --> <!-- CONNECTING 底部弹窗 -->
<view v-if="currentState === 'CONNECTING'" class="sheet-mask"> <view v-if="currentState === 'CONNECTING'" class="sheet-mask">
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'px' }" @click.stop=""> <view class="sheet" :style="{ marginBottom: keyboardHeight + 'rpx' }" @click.stop="">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" /> <image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
<view class="sheet-inner"> <view class="sheet-inner">
@@ -646,20 +766,13 @@ onUnmounted(() => {
</view> </view>
</view> </view>
<!-- 授权拒绝弹窗 --> <ModalDialog
<ScreenHint :show="wifiAuthDeniedVisible" :onClose="() => (wifiAuthDeniedVisible = false)"> :show="wifiSuccessVisible"
<view class="wifi-auth-denied"> content="WIFI连接成功"
<text class="wifi-auth-denied-text">拒绝授权获取wifi列表失败,请重新授权或手动输入</text> confirmText="确定"
<view class="wifi-auth-denied-btns"> :showCancel="false"
<view class="wifi-auth-denied-btn cancel-btn" @click="wifiAuthDeniedVisible = false"> :onConfirm="confirmWifiSuccess"
<text>取消授权</text> ></ModalDialog>
</view>
<view class="wifi-auth-denied-btn confirm-btn" @click="handleReauthorize">
<text>重新授权</text>
</view>
</view>
</view>
</ScreenHint>
<ModalDialog <ModalDialog
:show="countdownVisible" :show="countdownVisible"
@@ -704,45 +817,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.wifi-auth-denied {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 40rpx 24rpx;
gap: 32rpx;
}
.wifi-auth-denied-text {
color: #fff;
font-size: 28rpx;
text-align: center;
line-height: 1.6;
}
.wifi-auth-denied-btns {
display: flex;
flex-direction: row;
gap: 24rpx;
width: 100%;
justify-content: center;
}
.wifi-auth-denied-btn {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.wifi-auth-denied-btn.cancel-btn {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.wifi-auth-denied-btn.confirm-btn {
background: rgba(254, 216, 71, 1);
color: #1a1a2e;
font-weight: 600;
}
.wifi-banner { .wifi-banner {
background-color: rgba(255, 200, 0, 0.12); background-color: rgba(255, 200, 0, 0.12);
border: 1rpx solid rgba(254, 216, 71, 0.5); border: 1rpx solid rgba(254, 216, 71, 0.5);
@@ -756,200 +830,239 @@ onUnmounted(() => {
} }
.wifi-page { .wifi-page {
padding: 0 67rpx 40rpx; box-sizing: border-box;
padding: 0 40rpx 54rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.hero-area { .hero-area {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 40rpx 0 24rpx; margin-top: 12rpx;
}
.hero-image {
width: 164rpx;
height: 152rpx;
} }
.page-title { .page-title {
color: rgba(91, 196, 255, 1); display: block;
color: #5fadff;
font-size: 36rpx; font-size: 36rpx;
line-height: 50rpx;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
margin-bottom: 8rpx; margin-top: 22rpx;
}
.connected-page-title {
color: #4DD9C8;
} }
.page-subtitle { .page-subtitle {
color: rgba(255, 255, 255, 0.5); display: block;
align-self: center;
width: 552rpx;
max-width: 100%;
color: #fff;
font-size: 24rpx; font-size: 24rpx;
line-height: 34rpx;
text-align: center; text-align: center;
margin-bottom: 32rpx; margin-top: 4rpx;
} }
.fail-title { .network-sections {
color: rgba(255, 100, 100, 1); margin-top: 60rpx;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
} }
.network-section:last-child {
.ios-guide { display: flex;
background-color: rgba(95, 173, 255, 0.12); flex: 1;
border-radius: 8rpx; flex-direction: column;
padding: 16rpx 24rpx; min-height: 0;
margin-bottom: 20rpx;
} }
.ios-guide-text { .connected-section {
color: rgba(95, 173, 255, 1); margin-bottom: 60rpx;
flex-shrink: 0;
}
.section-label {
display: block;
color: rgba(255, 255, 255, 0.6);
font-size: 26rpx; font-size: 26rpx;
line-height: 36rpx;
} }
.section-label-row { .section-label-row {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 24rpx; justify-content: space-between;
padding: 0 22rpx; height: 36rpx;
} margin: 0 40rpx 20rpx;
.connected-section-label-row {
margin-top: 32rpx;
}
.section-label {
color: rgba(255, 255, 255, 0.56);
font-size: 28rpx;
}
.wifi-list-card {
background-color: rgba(30, 35, 50, 0.96);
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 20rpx;
}
.wifi-list-scroll {
flex-shrink: 0; flex-shrink: 0;
} }
.connected-wifi-card { .connected-section > .section-label {
background-color: rgba(35, 40, 56, 0.96); margin: 0 40rpx 20rpx;
margin-bottom: 44rpx;
} }
.wifi-item { .refresh-icon {
position: relative; width: 30rpx;
height: 30rpx;
}
.connected-wifi-card,
.ios-network-card,
.android-list-card {
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 24rpx;
overflow: hidden;
}
.connected-wifi-card {
height: 104rpx;
padding: 0 40rpx;
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 92rpx; justify-content: space-between;
padding: 0 26rpx 0 66rpx;
}
.wifi-item:not(:last-child)::after {
content: "";
position: absolute;
left: 66rpx;
right: 26rpx;
bottom: 0;
height: 1rpx;
background-color: rgba(255, 255, 255, 0.08);
}
.connected-wifi-item {
min-height: 88rpx;
padding-left: 28rpx;
} }
.wifi-ssid { .wifi-ssid {
color: rgba(255, 255, 255, 1); display: block;
font-size: 28rpx; min-width: 0;
line-height: 36rpx; flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #fff;
font-size: 32rpx;
line-height: 44rpx;
margin-bottom: 8rpx;
}
.ios-network-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
}
.current-network-card {
height: 124rpx;
}
.manual-network-card {
height: 156rpx;
margin-top: 20rpx;
}
.network-copy {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
flex: 1; flex: 1;
} }
.connected-wifi-ssid { .network-desc {
font-weight: 500; display: block;
color: rgba(255, 255, 255, 0.6);
font-size: 22rpx;
line-height: 32rpx;
} }
.other-text { .manual-network-card .network-desc {
color: rgba(255, 255, 255, 0.86); max-width: 484rpx;
}
.android-list-card {
padding: 0 40rpx;
flex: 1;
height: 0;
min-height: 0;
}
.android-network-item {
box-sizing: border-box;
height: 108rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.15);
}
.android-network-item:last-child {
border-bottom: none;
} }
.wifi-icons { .wifi-icons {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12rpx; gap: 16rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.connected-wifi-icons {
gap: 14rpx;
}
.check-icon { .check-icon {
width: 28rpx; width: 40rpx;
height: 24rpx; height: 32rpx;
margin-right: 16rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.security-icon { .security-icon {
width: 24rpx; width: 24rpx;
height: 30rpx; height: 28rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.signal-icon { .signal-icon {
width: 36rpx; width: 40rpx;
height: 32rpx; height: 32rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.wifi-footer {
.bottom-btn-area { display: block;
margin-top: 24rpx; flex-shrink: 0;
} align-self: center;
.connected-bottom-btn-area { width: 550rpx;
display: flex; max-width: 100%;
justify-content: center; margin-top: auto;
margin-top: 20rpx; padding-top: 60rpx;
} color: rgba(255, 255, 255, 0.6);
font-size: 22rpx;
.primary-btn { line-height: 32rpx;
background-color: rgba(254, 216, 71, 1);
border-radius: 50rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.update-btn {
width: 412rpx;
}
.primary-btn-text {
color: rgba(0, 0, 0, 1);
font-size: 30rpx;
font-weight: 500;
}
.center-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx 40rpx;
}
.page-desc-white {
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
line-height: 52rpx;
text-align: center; text-align: center;
} }
.done-btn { .location-page {
width: 80%; box-sizing: border-box;
} padding: 0 40rpx 54rpx;
.progress-wrap {
width: 80%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16rpx;
margin-top: 40rpx;
} }
.progress-track { .location-image-wrap {
width: 100%; position: relative;
height: 10rpx; width: 200rpx;
background-color: rgba(255, 255, 255, 0.15); height: 266rpx;
border-radius: 5rpx; margin-top: 212rpx;
overflow: hidden;
} }
.progress-fill { .location-image {
height: 100%; position: relative;
background-color: rgba(91, 196, 255, 1); z-index: 1;
border-radius: 5rpx; width: 200rpx;
transition: width 0.4s ease; height: 266rpx;
} }
.progress-pct { .location-shadow {
color: rgba(91, 196, 255, 1); position: absolute;
left: 34rpx;
bottom: -22rpx;
width: 132rpx;
height: 50rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.08);
}
.location-desc {
display: block;
width: 560rpx;
max-width: 100%;
margin-top: 48rpx;
color: #fff;
font-size: 28rpx; font-size: 28rpx;
line-height: 40rpx;
text-align: center;
}
.location-button {
box-sizing: border-box;
width: 364rpx;
height: 74rpx;
margin-top: 38rpx;
border: 2rpx solid #ffd800;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fed847;
font-size: 26rpx;
font-weight: 600; font-weight: 600;
} }
+83 -139
View File
@@ -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";
@@ -77,6 +78,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
scoreSlot: {
type: Number,
default: 0,
},
isVip: { isVip: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -107,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",
@@ -139,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(
@@ -183,8 +191,12 @@ const progressPercent = computed(() => {
return Math.max(0, Math.min(100, (remain.value / props.total) * 100)); return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
}); });
const stabilityEnergyCostText = computed(() => { const stabilityEnergyCostPercentText = computed(() => {
const value = Math.max(0, Number(props.energyCostPerSec) || 0); const scoreSlot = Number(props.scoreSlot);
// 以满能量值为基数,换算每秒损失的能量百分比。
const value = scoreSlot > 0
? (Math.max(0, Number(props.energyCostPerSec) || 0) / scoreSlot) * 100
: 0;
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
}); });
@@ -215,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(() => {
@@ -298,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 =
@@ -356,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;
@@ -374,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;
@@ -383,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 &&
@@ -454,6 +493,7 @@ watch(
watch( watch(
() => [ () => [
props.start,
props.trainingType, props.trainingType,
props.roundTime, props.roundTime,
props.shootTime, props.shootTime,
@@ -485,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;
@@ -542,6 +582,7 @@ onBeforeUnmount(() => {
clearTimer(); clearTimer();
rhythmSyncGeneration += 1; rhythmSyncGeneration += 1;
clearRhythmTimers(); clearRhythmTimers();
clearRhythmRoundCompletionTimer();
}); });
</script> </script>
@@ -558,7 +599,7 @@ onBeforeUnmount(() => {
> >
<template v-if="isStabilityTraining"> <template v-if="isStabilityTraining">
<text class="stability-progress__cost"> <text class="stability-progress__cost">
每秒失去{{ stabilityEnergyCostText }}点能量 每秒失去{{ stabilityEnergyCostPercentText }}%能量
</text> </text>
<view v-if="countdownEnabled" class="stability-progress__countdown"> <view v-if="countdownEnabled" class="stability-progress__countdown">
<view <view
@@ -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>
+33 -31
View File
@@ -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),
+56 -71
View File
@@ -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();
} }
} }
@@ -1855,6 +1840,7 @@ onBeforeUnmount(() => {
:serverTimestamp="rhythmCountdownTimestamp" :serverTimestamp="rhythmCountdownTimestamp"
:hitReq="rhythmHitReq" :hitReq="rhythmHitReq"
:energyCostPerSec="stabilityEnergyCostPerSec" :energyCostPerSec="stabilityEnergyCostPerSec"
:scoreSlot="stabilityScoreSlot"
:isVip="isVip" :isVip="isVip"
:isSvip="isSvip" :isSvip="isSvip"
:externalShootResultAudio=" :externalShootResultAudio="
@@ -1863,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" /> -->
@@ -2138,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{
@@ -2154,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>
+30 -6
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+70
View File
@@ -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) };
};