15 Commits

34 changed files with 1673 additions and 29 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
.history .history
.github .github
.claude
openspec openspec
CLAUDE.md CLAUDE.md
docs docs

View File

@@ -1,3 +1,6 @@
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
export const audioFils = { export const audioFils = {
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3", tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3", 点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
@@ -97,7 +100,7 @@ function debugLog(...args) {
const envVersion = accountInfo.miniProgram.envVersion; const envVersion = accountInfo.miniProgram.envVersion;
// 只在体验版打印日志,正式版(release)和开发版(develop)不打印 // 只在体验版打印日志,正式版(release)和开发版(develop)不打印
if (envVersion === "trial") { if (envVersion === "trial" || envVersion === "develop") {
console.log(...args); console.log(...args);
} }
} }
@@ -127,6 +130,7 @@ class AudioManager {
// 防重复播放保护 // 防重复播放保护
this.lastPlayKey = null; this.lastPlayKey = null;
this.lastPlayAt = 0; this.lastPlayAt = 0;
this.isInterrupted = false;
// 静音开关 // 静音开关
this.isMuted = false; this.isMuted = false;
@@ -141,10 +145,41 @@ class AudioManager {
this.localFileCache = uni.getStorageSync("audio_local_files") || {}; this.localFileCache = uni.getStorageSync("audio_local_files") || {};
// 启动时自动清理过期的缓存文件URL 已不在 audioFils 中的文件) // 启动时自动清理过期的缓存文件URL 已不在 audioFils 中的文件)
this.cleanObsoleteCache(); this.cleanObsoleteCache();
this.bindAudioInterruptionEvents();
this.initAudios(); this.initAudios();
} }
bindAudioInterruptionEvents() {
if (this._audioInterruptionBound) return;
this._audioInterruptionBound = true;
const begin = () => {
if (this.isInterrupted) return;
this.isInterrupted = true;
this.stopAll();
this.isSequenceRunning = false;
this.sequenceQueue = [];
this.sequenceIndex = 0;
this.pendingPlayKey = null;
uni.$emit(AUDIO_INTERRUPTION_BEGIN_EVENT);
};
const end = () => {
if (!this.isInterrupted) return;
this.isInterrupted = false;
uni.$emit(AUDIO_INTERRUPTION_END_EVENT);
void this.reloadAll();
};
if (typeof uni?.onAudioInterruptionBegin === "function") {
uni.onAudioInterruptionBegin(begin);
}
if (typeof uni?.onAudioInterruptionEnd === "function") {
uni.onAudioInterruptionEnd(end);
}
}
// 清理不再使用的缓存文件 // 清理不再使用的缓存文件
cleanObsoleteCache() { cleanObsoleteCache() {
const activeUrls = new Set(Object.values(audioFils)); const activeUrls = new Set(Object.values(audioFils));
@@ -461,6 +496,10 @@ class AudioManager {
// 播放指定音频或音频数组(数组则按顺序连续播放) // 播放指定音频或音频数组(数组则按顺序连续播放)
play(input, interrupt = true) { play(input, interrupt = true) {
if (this.isInterrupted) {
debugLog("音频处理中断状态,忽略播放请求");
return;
}
// 统一规范化为队列 // 统一规范化为队列
let queue = []; let queue = [];
if (Array.isArray(input)) { if (Array.isArray(input)) {
@@ -514,6 +553,10 @@ class AudioManager {
// 内部方法:播放单个 key // 内部方法:播放单个 key
_playSingle(key, forceStopAll = false) { _playSingle(key, forceStopAll = false) {
if (this.isInterrupted) {
debugLog(`音频处理中断状态,跳过播放: ${key}`);
return;
}
// 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音 // 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音
const now = Date.now(); const now = Date.now();
if (this.lastPlayKey === key && now - this.lastPlayAt < 250) { if (this.lastPlayKey === key && now - this.lastPlayAt < 250) {
@@ -557,7 +600,13 @@ class AudioManager {
// 显式授权播放并立即播放 // 显式授权播放并立即播放
this.allowPlayMap.set(key, true); this.allowPlayMap.set(key, true);
try {
audio.play(); audio.play();
} catch (err) {
this.allowPlayMap.set(key, false);
debugLog(`音频 ${key} 播放调用失败`, err?.errMsg || err);
return;
}
this.currentPlayingKey = key; this.currentPlayingKey = key;
this.lastPlayKey = key; this.lastPlayKey = key;
this.lastPlayAt = Date.now(); this.lastPlayAt = Date.now();

396
src/components/OtaModal.vue Normal file
View File

@@ -0,0 +1,396 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
state: {
type: String,
default: "new_version", // new_version | update_progress | update_success | update_failure
},
version: {
type: String,
default: "",
},
progress: {
type: Number,
default: 40,
},
// 副标题:如“新版本将优化智能弓体验”
description: {
type: String,
default: "",
},
// 详细说明:如“升级前请确保:...”
changelog: {
type: String,
default: "",
},
forceUpdate: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update", "skip", "close", "done", "retry"]);
const isNewVersion = computed(() => props.state === "new_version");
const isProgress = computed(() => props.state === "update_progress");
const isSuccess = computed(() => props.state === "update_success");
const isFailure = computed(() => props.state === "update_failure");
// Clamp progress to keep the progress bar width within its container.
const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.progress) || 0)));
</script>
<template>
<view v-if="visible" class="ota-mask">
<!-- 图标 + 弹窗卡片 容器 -->
<view
class="ota-outer"
:class="isNewVersion ? 'outer-new' : 'outer-result'"
>
<!-- 悬浮图标溢出卡片顶部 -->
<image
v-if="isNewVersion"
src="../static/ota/ota-mascot.png"
mode="aspectFit"
class="float-icon float-mascot"
/>
<image
v-else-if="isSuccess"
src="../static/ota/check-char.png"
mode="aspectFit"
class="float-icon float-check"
/>
<image
v-else-if="isFailure"
src="../static/ota/close-char.png"
mode="aspectFit"
class="float-icon float-close"
/>
<image
v-else-if="isProgress"
src="../static/ota/target-char.png"
mode="aspectFit"
class="float-icon float-target"
/>
<!-- 弹窗卡片overflow:visible 允许按钮溢出底部背景图通过 ota-bg-clip 独立裁剪保持圆角 -->
<view class="ota-dialog">
<view class="ota-bg-clip">
<image src="../static/ota/ota-bg.png" mode="aspectFill" class="ota-bg" />
</view>
<view
class="ota-content"
:class="{ 'content-new': isNewVersion, 'content-result': isProgress || isSuccess || isFailure }"
>
<!-- 发现新版本new-ver.png 已包含标题图不再重复文字版本号使用 ota-ver.png 胶囊背景 -->
<block v-if="isNewVersion">
<image src="../static/ota/new-ver.png" mode="aspectFit" class="new-ver-img" />
<view v-if="version" class="version-tag-wrap">
<image src="../static/ota/ota-ver.png" mode="aspectFill" class="version-tag-bg-img" />
<text class="version-tag">{{ version }}</text>
</view>
<!-- 副标题新版本将优化智能弓体验离下方详情 12rpx -->
<text v-if="description" class="desc-text">{{ description }}</text>
<!-- 详细说明升级前请确保... -->
<text v-if="changelog" class="changelog-text">{{ changelog }}</text>
<view class="btn-group">
<view class="primary-btn" @click="emit('update')">
<text class="primary-btn-text">立即更新</text>
</view>
<text v-if="!forceUpdate" class="skip-text" @click="emit('skip')">暂不更新</text>
</view>
</block>
<!-- 更新成功图片左边距 34rpx文案左边距 44rpx按钮浮动底部居中 -->
<block v-else-if="isSuccess">
<image src="../static/ota/update-ok.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<text class="dialog-desc">请关机并重启智能弓</text>
<view class="btn-group-result">
<view class="primary-btn" @click="emit('done')">
<text class="primary-btn-text">完成</text>
</view>
</view>
</block>
<!-- 更新中复用成功标题图正文区域展示进度条无底部按钮 -->
<block v-else-if="isProgress">
<image src="../static/ota/update_progress.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<view class="progress-wrap">
<view class="progress-track">
<view class="progress-fill" :style="{ width: `${progressValue}%` }"></view>
</view>
</view>
</block>
<!-- 更新失败图片左边距 34rpx文案左对齐 44rpx按钮浮动底部居中 -->
<block v-else-if="isFailure">
<image src="../static/ota/update-fail.png" mode="aspectFit" class="result-title-img" style="width: 222rpx; height: 62rpx;" />
<text class="dialog-desc">请确保</text>
<text class="dialog-desc">1智能弓已开启</text>
<text class="dialog-desc">2网路连接稳定</text>
<view class="btn-group-result">
<view class="primary-btn" @click="emit('retry')">
<text class="primary-btn-text">重试</text>
</view>
</view>
</block>
</view>
</view>
</view>
<!-- 关闭按钮仅新版本状态非强制更新时位于弹窗下方 -->
<view
v-if="isNewVersion && !forceUpdate"
class="ota-close-below"
@click="emit('close')"
>
<image src="../static/sicon/close.png" mode="aspectFit" style="width: 56rpx; height: 56rpx;" />
</view>
</view>
</template>
<style scoped>
.ota-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 外层容器:相对定位,为浮动图标创造溢出空间 */
.ota-outer {
position: relative;
overflow: visible;
}
/* 设计图:吉祥物向上突出弹窗顶部 40px375px基准× 2 = 80rpx */
.outer-new {
padding-top: 80rpx;
}
.outer-result {
padding-top: 80rpx;
padding-bottom: 66rpx;
}
/* 浮动图标(绝对定位,位于卡片顶部上方) */
.float-icon {
position: absolute;
z-index: 2;
}
/* 吉祥物尺寸:设计图 149×109px375px基准× 2 = 298×218rpx */
.float-mascot {
width: 298rpx;
height: 218rpx;
top: -5px;
right: -74rpx;
}
.float-check {
width: 194rpx;
height: 166rpx;
top: 20px;
right: 30rpx;
}
.float-close {
width: 194rpx;
height: 164rpx;
top: 20px;
right: 30rpx;
}
.float-target {
width: 194rpx;
height: 166rpx;
top: 20px;
right: 30rpx;
}
/* 弹窗卡片overflow:visible 允许按钮溢出底部,背景通过 ota-bg-clip 独立裁剪 */
.ota-dialog {
position: relative;
width: 482rpx;
border-radius: 24rpx;
border: 2rpx solid #F9D5A1;
overflow: visible;
background-color: #392F1D;
}
/* 背景图裁剪层:独立 overflow:hidden + border-radius 保持圆角效果 */
.ota-bg-clip {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 24rpx;
overflow: hidden;
z-index: 0;
}
.ota-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.ota-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
/* 按钮以外内容均左对齐 */
align-items: flex-start;
}
.content-new {
padding: 30rpx 0 40rpx 0;
}
.content-result {
padding: 30rpx 0 66rpx 0;
}
/* 发现新版本内容 */
.new-ver-img {
width: 274rpx;
height: 62rpx;
/* 左边距 34rpx去掉 margin-bottom */
margin-left: 34rpx;
}
/* 版本号胶囊容器:相对定位,使 ota-ver.png 作为背景衬底 */
.version-tag-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
/* 离标题图 -10rpx左边距 50rpx离下方副标题 22rpx */
margin-top: -10rpx;
margin-left: 50rpx;
margin-bottom: 22rpx;
}
/* ota-ver.png 胶囊背景图 */
.version-tag-bg-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* 版本号文字:浮于背景图之上 */
.version-tag {
position: relative;
z-index: 1;
color: rgba(254, 222, 100, 1);
font-size: 24rpx;
padding: 8rpx 22rpx 4rpx 24rpx;
}
/* 副标题(如"新版本将优化智能弓体验"):左边距 44rpx离下方文案 12rpx */
.desc-text {
font-weight: 500;
font-size: 26rpx;
color: #FFFFFF;
line-height: 36rpx;
text-align: left;
margin-left: 44rpx;
margin-bottom: 12rpx;
}
/* 详细说明文案(如“升级前请确保:...”):左边距 44rpx */
.changelog-text {
font-weight: 400;
font-size: 26rpx;
color: #FFFFFF;
line-height: 40rpx;
text-align: left;
margin-left: 44rpx;
margin-bottom: 0;
}
/* 按钮组(新版本状态):离上方文案 30rpx内部按钮间距 24rpx */
.btn-group {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30rpx;
gap: 24rpx;
}
/* 按钮组(结果状态):绝对定位,溢出卡片底边 -35rpx 悬浮在底边中间 */
.btn-group-result {
position: absolute;
bottom: -35rpx;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
/* 主按钮:按照设计规范 width: 232rpx, height: 70rpx */
.primary-btn {
width: 232rpx;
height: 70rpx;
background-color: #FED847;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.primary-btn-text {
font-weight: 500;
font-size: 26rpx;
color: #000000;
line-height: 36rpx;
}
/* 暂不更新:设计规范颜色 #5FADFF 蓝色 */
.skip-text {
font-weight: 400;
font-size: 26rpx;
color: #5FADFF;
line-height: 36rpx;
}
/* 更新结果内容:图片左边距 34rpx下边距 16rpx */
.result-title-img {
margin-left: 34rpx;
margin-bottom: 16rpx;
}
/* 结果页文案:左对齐,左边距 44rpx与 new_version 保持一致 */
.dialog-desc {
font-weight: 400;
font-size: 26rpx;
color: #FFFFFF;
line-height: 40rpx;
text-align: left;
margin-left: 44rpx;
}
.progress-wrap {
width: 394rpx;
margin-top: 40rpx;
margin-left: 44rpx;
}
.progress-track {
width: 100%;
height: 18rpx;
background-color: rgba(255, 255, 255, 0.28);
border-radius: 999rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #FED847;
border-radius: 999rpx;
}
/* 关闭按钮(位于弹窗下方) */
.ota-close-below {
margin-top: 40rpx;
display: flex;
justify-content: center;
}
</style>

View File

@@ -64,7 +64,11 @@
"usingComponents" : true, "usingComponents" : true,
"darkmode" : true, "darkmode" : true,
"themeLocation" : "theme.json", "themeLocation" : "theme.json",
"permission" : {}, "permission" : {
"scope.userLocation": {
"desc": "用于扫描附近 WiFi完成设备 OTA 升级网络连接"
}
},
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ] "requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
} }
} }

View File

@@ -119,6 +119,12 @@
}, },
{ {
"path": "pages/mine-bow-data" "path": "pages/mine-bow-data"
},
{
"path": "pages/ota-wifi",
"style": {
"navigationStyle": "custom"
}
} }
], ],
"globalStyle": { "globalStyle": {

View File

@@ -6,6 +6,7 @@ import AppFooter from "@/components/AppFooter.vue";
import UserHeader from "@/components/UserHeader.vue"; import UserHeader from "@/components/UserHeader.vue";
import Signin from "@/components/Signin.vue"; import Signin from "@/components/Signin.vue";
import BubbleTip from "@/components/BubbleTip.vue"; import BubbleTip from "@/components/BubbleTip.vue";
import OtaModal from "@/components/OtaModal.vue";
import { import {
checkUserBindAPI, checkUserBindAPI,
@@ -36,6 +37,44 @@ const showModal = ref(false);
const showGuide = ref(false); const showGuide = ref(false);
const scoreRankList = ref([]); const scoreRankList = ref([]);
// OTA 相关
const otaVisible = ref(false);
const otaState = ref("new_version");
const OTA_MOCK = {
hasUpdate: true,
version: "V8.7.0",
description: "新版本将优化智能弓体验",
details: "升级前请确保:\n1、智能弓已开启且电量充足\n2、所处稳定的 Wi-Fi 环境中。",
forceUpdate: false,
};
const checkOtaUpdate = () => {
if (!OTA_MOCK.hasUpdate) return;
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
const now = Date.now();
if (dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
otaState.value = "new_version";
otaVisible.value = true;
};
const handleOtaDismiss = () => {
uni.setStorageSync("ota_dismissed_at", Date.now());
otaVisible.value = false;
};
const handleOtaUpdate = () => {
otaVisible.value = false;
uni.navigateTo({ url: "/pages/ota-wifi" });
};
const handleOtaDone = () => {
otaVisible.value = false;
};
const handleOtaRetry = () => {
otaVisible.value = false;
};
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。 // 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
const getScoreRankData = (result) => { const getScoreRankData = (result) => {
if (Array.isArray(result)) return result; if (Array.isArray(result)) return result;
@@ -63,7 +102,15 @@ const toRankListPage = () => {
}); });
}; };
onShow(async () => { onShow(async (options) => {
// 检查是否从 OTA 更新页面返回
if (options && options.updateResult) {
otaState.value = options.updateResult;
otaVisible.value = true;
} else {
checkOtaUpdate();
}
const env = uni.getAccountInfoSync().miniProgram.envVersion; const env = uni.getAccountInfoSync().miniProgram.envVersion;
const token = uni.getStorageSync(`${env}_token`); const token = uni.getStorageSync(`${env}_token`);
@@ -158,6 +205,20 @@ onShareTimeline(() => {
<template> <template>
<Container :isHome="true" :showBackToGame="true"> <Container :isHome="true" :showBackToGame="true">
<!-- OTA 升级弹窗使用 visible 控制显隐description 为副标题changelog 为详细说明 -->
<OtaModal
:visible="otaVisible"
:state="otaState"
:version="OTA_MOCK.version"
:description="OTA_MOCK.description"
:changelog="OTA_MOCK.details"
:forceUpdate="OTA_MOCK.forceUpdate"
@update="handleOtaUpdate"
@skip="handleOtaDismiss"
@close="handleOtaDismiss"
@done="handleOtaDone"
@retry="handleOtaRetry"
/>
<view class="container"> <view class="container">
<view class="top-theme"> <view class="top-theme">
<!-- <image <!-- <image

View File

@@ -93,6 +93,11 @@ const unbindDevice = async () => {
device.value = {}; device.value = {};
}; };
/** 连接wifi跳转到wifi列表页面 */
const joinWifi = () => {
uni.navigateTo({ url: "/pages/ota-wifi" });
};
const toDeviceIntroPage = () => { const toDeviceIntroPage = () => {
uni.navigateTo({ uni.navigateTo({
url: "/pages/device-intro", url: "/pages/device-intro",
@@ -273,6 +278,11 @@ onShow(() => {
>解绑</SButton >解绑</SButton
> >
</view> </view>
<view :style="{ marginTop: '20rpx' }">
<SButton :onClick="() => $clickSound(joinWifi)" width="80vw" :rounded="40"
>设备连接WIFI</SButton
>
</view>
</view> </view>
</Container> </Container>
</template> </template>

979
src/pages/ota-wifi.vue Normal file
View File

@@ -0,0 +1,979 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ScreenHint from "@/components/ScreenHint.vue";
const STATES = {
SCANNING: "SCANNING",
LIST: "LIST",
CONNECTING: "CONNECTING",
CONNECTED: "CONNECTED",
UPDATING: "UPDATING",
DONE: "DONE",
FAILED: "FAILED",
};
const isIOS = uni.getDeviceInfo().osName === "ios";
const currentState = ref(STATES.SCANNING);
const connectedWifi = ref(null);
const wifiList = ref([]);
const showWifiBanner = ref(false);
const connectingWifi = ref(null);
const connectInput = ref({ ssid: "", password: "" });
const connectMode = ref("secure"); // secure | open | manual
const connectError = ref("");
const keyboardHeight = ref(0);
// 控制密码输入框是否显示明文
const showPassword = ref(false);
// 刷新防抖标志:扫描进行中为 true禁止重复点击扫描结束成功/失败)后重置为 false。
const isRefreshing = ref(false);
const progress = ref(0);
let progressTimer = null;
let timeoutTimer = null;
// 控制授权拒绝弹窗显示/隐藏
const wifiAuthDeniedVisible = ref(false);
// 判断 WiFi 列表失败是否由用户拒绝授权引起(兼容 errno:103 及各平台 errMsg 变体)。
const isWifiPermissionDenied = (err) => {
if (err?.errno === 103) return true;
const errMsg = err?.errMsg || "";
return /auth den|authorize|permission denied|user denied|scope\.userLocation/i.test(errMsg);
};
// 显示授权拒绝弹窗,通过 ScreenHint 组件呈现。
const showWifiPermissionDeniedModal = () => {
wifiAuthDeniedVisible.value = true;
};
// 用户点击「重新授权」时,打开小程序权限设置页,引导用户开启位置权限后重新扫描。
const handleReauthorize = () => {
wifiAuthDeniedVisible.value = false;
uni.openSetting({
success(res) {
// 若用户在设置页开启了位置权限,则重新启动 WiFi 扫描
if (res.authSetting["scope.userLocation"]) {
startScanning();
}
},
});
};
// 将 iOS 当前手机连接的 WiFi 写入候选列表,供用户继续填写设备连接密码;扫描结束后重置刷新防抖标志。
const applyIOSConnectedWifi = (wifi) => {
const ssid = wifi?.SSID;
if (!ssid) {
wifiList.value = [];
currentState.value = STATES.LIST;
isRefreshing.value = false;
return;
}
wifiList.value = [
{
...wifi,
SSID: ssid,
secure: wifi.secure !== false,
fromCurrentWifi: true,
},
];
currentState.value = STATES.LIST;
isRefreshing.value = false;
};
// 获取 iOS 当前手机连接的 WiFi并把它作为可选网络展示。
const syncIOSConnectedWifi = () => {
wx.getConnectedWifi({
success(res) {
console.log("[OTA WiFi] wx.getConnectedWifi success:", res);
applyIOSConnectedWifi(res.wifi || res);
},
fail(err) {
console.warn("[OTA WiFi] wx.getConnectedWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
wifiList.value = [];
currentState.value = STATES.LIST;
isRefreshing.value = false;
},
});
};
// 启动 iOS WiFi 能力iOS 只用当前手机连接的 WiFi 作为候选网络。
const startIOSScanning = () => {
wx.startWifi({
success(res) {
console.log("[OTA WiFi] wx.startWifi success:", res);
syncIOSConnectedWifi();
},
fail(err) {
console.error("[OTA WiFi] wx.startWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (err.errCode === 12005) showWifiBanner.value = true;
currentState.value = STATES.LIST;
isRefreshing.value = false;
},
});
};
// 启动小程序 WiFi 能力并按平台获取可用网络;也作为刷新按钮的点击处理器,重新获取最新 WiFi 列表。
// 扫描进行中时防抖:若上次扫描尚未完成则直接 return避免重复触发导致弹窗异常。
const startScanning = () => {
if (isRefreshing.value) return;
isRefreshing.value = true;
currentState.value = STATES.SCANNING;
wifiList.value = [];
showWifiBanner.value = false;
connectError.value = "";
if (isIOS) {
startIOSScanning();
return;
}
wx.startWifi({
success(res) {
console.log("[OTA WiFi] wx.startWifi success:", res);
wx.getWifiList({
success(res) {
console.log("[OTA WiFi] wx.getWifiList request success:", res);
},
fail(err) {
console.error("[OTA WiFi] wx.getWifiList fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false;
},
});
},
fail(err) {
console.error("[OTA WiFi] wx.startWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false;
},
});
wx.onGetWifiList((res) => {
const realWifiList = res.wifiList || [];
if (realWifiList.length) {
console.log("[OTA WiFi] wx.onGetWifiList received wifiList:", realWifiList);
} else {
console.warn("[OTA WiFi] wx.onGetWifiList received empty wifiList:", res);
}
wifiList.value = realWifiList;
currentState.value = STATES.LIST;
isRefreshing.value = false;
});
};
const selectWifi = (wifi) => {
connectingWifi.value = wifi;
connectInput.value = { ssid: wifi.SSID, password: "" };
connectMode.value = wifi.secure ? "secure" : "open";
connectError.value = "";
currentState.value = STATES.CONNECTING;
};
const selectOther = () => {
connectingWifi.value = null;
connectInput.value = { ssid: "", password: "" };
connectMode.value = "manual";
connectError.value = "";
currentState.value = STATES.CONNECTING;
};
const closeConnectSheet = () => {
connectError.value = "";
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
};
const hasChinese = (str) => /[\u4e00-\u9fa5]/.test(str);
const ssidWarning = computed(() => {
if (connectMode.value === "manual" && hasChinese(connectInput.value.ssid)) {
return "网络名称仅支持英文字符及数字,请连接英文名网络或把网络改为英文";
}
return "";
});
const joinDisabled = computed(() => {
if (connectMode.value === "secure") return !connectInput.value.password;
if (connectMode.value === "manual") return !connectInput.value.ssid;
return false;
});
// 按当前页面状态限制 WiFi 列表高度,避免列表撑开整页。
const wifiListScrollHeight = computed(() => {
if (currentState.value === STATES.SCANNING) return "auto";
const itemCount = wifiList.value.length + 1;
const maxHeight = currentState.value === STATES.CONNECTED ? 276 : 420;
return `${Math.min(itemCount * 92, maxHeight)}rpx`;
});
// 提交 WiFi 配置给游戏设备;后端接口未接入前只提示占位信息并停留在弹窗。
const submitDeviceWifiConfig = ({ ssid, password }) => {
console.log("[OTA WiFi] submit device wifi config pending:", {
ssid,
hasPassword: !!password,
});
connectError.value = "设备连接接口待接入";
uni.showToast({
title: "设备连接接口待接入",
icon: "none",
});
};
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
const joinNetwork = () => {
if (joinDisabled.value) return;
connectError.value = "";
const ssid = connectInput.value.ssid;
const password = connectInput.value.password;
submitDeviceWifiConfig({ ssid, password });
};
const startUpdate = () => {
currentState.value = STATES.UPDATING;
progress.value = 0;
clearInterval(progressTimer);
progressTimer = setInterval(() => {
if (progress.value >= 90) {
clearInterval(progressTimer);
return;
}
const increment = Math.max(0.5, 2 - progress.value / 60);
progress.value = Math.min(90, progress.value + increment);
}, 500);
clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(() => {
if (currentState.value === STATES.UPDATING) {
clearInterval(progressTimer);
currentState.value = STATES.FAILED;
}
}, 5 * 60 * 1000);
};
const handleWsDone = () => {
clearInterval(progressTimer);
clearTimeout(timeoutTimer);
progress.value = 100;
setTimeout(() => {
currentState.value = STATES.DONE;
}, 300);
};
const handleWsFail = () => {
clearInterval(progressTimer);
clearTimeout(timeoutTimer);
currentState.value = STATES.FAILED;
};
const handleDone = () => {
uni.navigateBack({
delta: 1,
success() {
const pages = getCurrentPages();
const prevPage = pages[pages.length - 2];
if (prevPage) {
prevPage.$vm.otaState = "update_success";
prevPage.$vm.otaVisible = true;
}
},
});
};
const handleRetry = () => {
if (connectedWifi.value) {
currentState.value = STATES.CONNECTED;
} else {
startScanning();
}
};
// 监听系统输入法高度,用于让底部弹窗避开键盘遮挡。
const handleKeyboardHeightChange = (res) => {
keyboardHeight.value = res?.height || 0;
};
// 切换密码显示状态前先收起键盘,避免安全键盘和普通输入法切换时复用旧高度产生黑条。
const togglePasswordVisibility = () => {
uni.hideKeyboard({
complete() {
keyboardHeight.value = 0;
showPassword.value = !showPassword.value;
},
});
};
// 页面挂载后启动 WiFi 扫描流程。
onMounted(() => {
if (typeof uni.onKeyboardHeightChange === "function") {
uni.onKeyboardHeightChange(handleKeyboardHeightChange);
}
startScanning();
});
// iOS 从系统 WiFi 设置返回小程序后,同步当前手机连接的 WiFi。
onShow(() => {
if (isIOS && currentState.value === STATES.LIST) {
syncIOSConnectedWifi();
}
});
onUnmounted(() => {
if (typeof uni.offKeyboardHeightChange === "function") {
uni.offKeyboardHeightChange(handleKeyboardHeightChange);
}
clearInterval(progressTimer);
clearTimeout(timeoutTimer);
wx.offGetWifiList && wx.offGetWifiList();
});
</script>
<template>
<Container title="连接无线网络">
<!-- WiFi 不可用 Banner -->
<view v-if="showWifiBanner" class="wifi-banner">
<text class="wifi-banner-text">请先开启 WiFi 后刷新重试</text>
</view>
<!-- SCANNING / LIST / CONNECTED -->
<view
v-if="currentState === 'SCANNING' || currentState === 'LIST' || currentState === 'CONNECTED'"
class="wifi-page"
>
<!-- Hero: wifi1.png 单图已内含吉祥物 -->
<view class="hero-area">
<image src="../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="../static/sicon/check.png" mode="aspectFit" />
<text class="wifi-ssid connected-wifi-ssid">{{ connectedWifi?.SSID }}</text>
<view class="wifi-icons connected-wifi-icons">
<image
v-if="connectedWifi?.secure"
class="security-icon"
src="../static/sicon/pwd.png"
mode="aspectFit"
/>
<image class="signal-icon" src="../static/sicon/wifi.png" mode="aspectFit" />
</view>
</view>
</view>
<!-- 网络列表标题 -->
<view class="section-label-row">
<text class="section-label">网络</text>
<image
src="../static/sicon/refresh.png"
mode="aspectFit"
:style="{ width: '34rpx', height: '34rpx', marginLeft: '8rpx', opacity: isRefreshing ? 0.3 : 0.7 }"
@click="startScanning"
/>
</view>
<!-- 网络列表卡片 -->
<scroll-view
scroll-y
class="wifi-list-card"
:class="{ 'wifi-list-scroll': currentState !== 'SCANNING' }"
:style="{ height: wifiListScrollHeight }"
>
<block v-if="currentState === 'SCANNING'">
<view class="wifi-item" @click="selectOther">
<text class="wifi-ssid other-text">其他...</text>
</view>
</block>
<block v-else>
<view
v-for="(wifi, idx) in wifiList"
:key="wifi.SSID"
class="wifi-item"
@click="selectWifi(wifi)"
>
<text class="wifi-ssid">{{ wifi.SSID }}</text>
<view class="wifi-icons">
<image
v-if="wifi.secure"
class="security-icon"
src="../static/sicon/pwd.png"
mode="aspectFit"
/>
<image class="signal-icon" src="../static/sicon/wifi.png" mode="aspectFit" />
</view>
</view>
<view class="wifi-item" @click="selectOther">
<text class="wifi-ssid other-text">其他...</text>
</view>
</block>
</scroll-view>
<!-- CONNECTED开始更新按钮 -->
<view v-if="currentState === 'CONNECTED'" class="bottom-btn-area connected-bottom-btn-area">
<view class="primary-btn update-btn" @click="startUpdate">
<text class="primary-btn-text">开始更新</text>
</view>
</view>
</view>
<!-- UPDATING -->
<view v-else-if="currentState === 'UPDATING'" class="center-page">
<image src="../static/ota/target-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新中,请稍等片刻...</text>
<view class="progress-wrap">
<view class="progress-track">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
</view>
<text class="progress-pct">{{ Math.floor(progress) }}%</text>
</view>
</view>
<!-- DONE -->
<view v-else-if="currentState === 'DONE'" class="center-page">
<image src="../static/ota/check-char.png" mode="aspectFit" style="width: 194rpx; height: 166rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新完成</text>
<text class="page-desc-white">请关机并重启智能弓</text>
<view class="primary-btn done-btn" style="margin-top:20px" @click="handleDone">
<text class="primary-btn-text">完成</text>
</view>
</view>
<!-- FAILED -->
<view v-else-if="currentState === 'FAILED'" class="center-page">
<image src="../static/ota/close-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title fail-title" style="margin-top: 24rpx;">更新失败</text>
<text class="page-desc-white">请确保</text>
<text class="page-desc-white">1智能弓已开启</text>
<text class="page-desc-white">2网路连接稳定</text>
<view class="primary-btn done-btn" style="margin-top: 40rpx;" @click="handleRetry">
<text class="primary-btn-text">重试</text>
</view>
</view>
<!-- CONNECTING 底部弹窗 -->
<view v-if="currentState === 'CONNECTING'" class="sheet-mask">
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'px' }" @click.stop="">
<image src="../static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
<view class="sheet-inner">
<!-- 有密码网络 -->
<block v-if="connectMode === 'secure'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入"{{ connectInput.ssid }}"</text>
<view
class="sheet-nav-btn"
:class="{ 'nav-disabled': joinDisabled }"
@click="joinNetwork"
>
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<view class="input-row-card">
<text class="input-label">密码</text>
<input
class="input-field"
:password="!showPassword"
:adjust-position="false"
v-model="connectInput.password"
placeholder="输入网络密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="joinNetwork"
/>
<!-- 密码显示/隐藏切换按钮 -->
<view class="pwd-eye-btn" @click="togglePasswordVisibility">
<image
:src="showPassword ? '../static/sicon/eye-on.png' : '../static/sicon/eye-off.png'"
mode="aspectFit"
style="width: 40rpx; height: 40rpx;"
/>
</view>
</view>
<text v-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
<!-- 无密码网络 -->
<block v-else-if="connectMode === 'open'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入"{{ connectInput.ssid }}"</text>
<view class="sheet-nav-btn" @click="joinNetwork">
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<text class="sheet-hint">该网络为开放网络点击 加入</text>
<text v-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
<!-- 手动输入 -->
<block v-else-if="connectMode === 'manual'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入无线网络</text>
<view
class="sheet-nav-btn"
:class="{ 'nav-disabled': joinDisabled }"
@click="joinNetwork"
>
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<view class="input-row-card">
<text class="input-label">名称</text>
<input
class="input-field"
v-model="connectInput.ssid"
placeholder="输入网络名称"
placeholder-class="input-placeholder"
/>
</view>
<view class="input-row-card">
<text class="input-label">密码</text>
<input
class="input-field"
:password="!showPassword"
:adjust-position="false"
v-model="connectInput.password"
placeholder="输入网络密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="joinNetwork"
/>
<!-- 密码显示/隐藏切换按钮 -->
<view class="pwd-eye-btn" @click="togglePasswordVisibility">
<image
:src="showPassword ? '../static/sicon/eye-on.png' : '../static/sicon/eye-off.png'"
mode="aspectFit"
style="width: 40rpx; height: 40rpx;"
/>
</view>
</view>
<text v-if="ssidWarning" class="connect-error">{{ ssidWarning }}</text>
<text v-else-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
</view>
</view>
</view>
<!-- 授权拒绝弹窗 -->
<ScreenHint :show="wifiAuthDeniedVisible" :onClose="() => (wifiAuthDeniedVisible = false)">
<view class="wifi-auth-denied">
<text class="wifi-auth-denied-text">拒绝授权获取wifi列表失败请重新授权或手动输入</text>
<view class="wifi-auth-denied-btns">
<view class="wifi-auth-denied-btn cancel-btn" @click="wifiAuthDeniedVisible = false">
<text>取消授权</text>
</view>
<view class="wifi-auth-denied-btn confirm-btn" @click="handleReauthorize">
<text>重新授权</text>
</view>
</view>
</view>
</ScreenHint>
</Container>
</template>
<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 {
background-color: rgba(255, 200, 0, 0.12);
border: 1rpx solid rgba(254, 216, 71, 0.5);
border-radius: 8rpx;
padding: 16rpx 24rpx;
margin: 16rpx 24rpx 0;
}
.wifi-banner-text {
color: rgba(254, 216, 71, 1);
font-size: 26rpx;
}
.wifi-page {
padding: 0 67rpx 40rpx;
display: flex;
flex-direction: column;
}
.hero-area {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0 24rpx;
}
.page-title {
color: rgba(91, 196, 255, 1);
font-size: 36rpx;
font-weight: 600;
text-align: center;
margin-bottom: 8rpx;
}
.connected-page-title {
color: #4DD9C8;
}
.page-subtitle {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
text-align: center;
margin-bottom: 32rpx;
}
.fail-title {
color: rgba(255, 100, 100, 1);
}
.ios-guide {
background-color: rgba(95, 173, 255, 0.12);
border-radius: 8rpx;
padding: 16rpx 24rpx;
margin-bottom: 20rpx;
}
.ios-guide-text {
color: rgba(95, 173, 255, 1);
font-size: 26rpx;
}
.section-label-row {
display: flex;
align-items: center;
margin-bottom: 24rpx;
padding: 0 22rpx;
}
.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;
}
.connected-wifi-card {
background-color: rgba(35, 40, 56, 0.96);
margin-bottom: 44rpx;
}
.wifi-item {
position: relative;
display: flex;
align-items: center;
min-height: 92rpx;
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 {
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
line-height: 36rpx;
flex: 1;
}
.connected-wifi-ssid {
font-weight: 500;
}
.other-text {
color: rgba(255, 255, 255, 0.86);
}
.wifi-icons {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.connected-wifi-icons {
gap: 14rpx;
}
.check-icon {
width: 28rpx;
height: 24rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.security-icon {
width: 24rpx;
height: 30rpx;
flex-shrink: 0;
}
.signal-icon {
width: 36rpx;
height: 32rpx;
flex-shrink: 0;
}
.bottom-btn-area {
margin-top: 24rpx;
}
.connected-bottom-btn-area {
display: flex;
justify-content: center;
margin-top: 20rpx;
}
.primary-btn {
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;
}
.done-btn {
width: 80%;
}
.progress-wrap {
width: 80%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-top: 40rpx;
}
.progress-track {
width: 100%;
height: 10rpx;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 5rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: rgba(91, 196, 255, 1);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.progress-pct {
color: rgba(91, 196, 255, 1);
font-size: 28rpx;
font-weight: 600;
}
.sheet-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.sheet {
position: relative;
background-color: rgba(57, 47, 29, 1);
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
overflow: hidden;
min-height: 320rpx;
border: 1rpx solid rgba(249, 213, 161, 0.4);
border-bottom: none;
}
.sheet-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.sheet-inner {
position: relative;
z-index: 1;
padding: 32rpx 32rpx 112rpx;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.sheet-nav-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-disabled {
opacity: 0.3;
}
.sheet-title {
color: rgba(255, 255, 255, 1);
font-size: 30rpx;
font-weight: 600;
flex: 1;
text-align: center;
}
.sheet-hint {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
text-align: center;
margin-bottom: 24rpx;
}
.connect-error {
color: rgba(254, 216, 71, 1);
font-size: 26rpx;
line-height: 40rpx;
margin-top: 16rpx;
display: block;
/* 错误提示文案居中展示 */
text-align: center;
}
.input-row-card {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
display: flex;
align-items: center;
padding: 24rpx 24rpx;
margin-left: 48rpx;
margin-right: 48rpx;
}
.input-row-card + .input-row-card {
margin-top: 16rpx;
}
.input-label {
color: rgba(255, 255, 255, 0.8);
font-size: 28rpx;
width: 80rpx;
flex-shrink: 0;
}
.input-field {
flex: 1;
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
background: transparent;
border: none;
padding: 0 8rpx;
}
.input-placeholder {
color: rgba(255, 255, 255, 0.3);
}
/* 密码查看切换按钮 */
.pwd-eye-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
</style>

View File

@@ -24,12 +24,14 @@ const onUpdateTips = (newVal) => {
// 监听 Pinia store 中 totalShot 变化,用于比赛恢复时同步箭数(替代 uni.$emit 避免时序问题) // 监听 Pinia store 中 totalShot 变化,用于比赛恢复时同步箭数(替代 uni.$emit 避免时序问题)
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决重入时 totalShot 值不变 watch 不触发的问题) // 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决重入时 totalShot 值不变 watch 不触发的问题)
watch(() => store.game.totalShot, (newVal) => { watch(
if (newVal > 0) { () => [store.game.currentShot, store.game.totalShot],
totalShot.value = newVal; ([newCurrentShot, newTotalShot]) => {
currentShot.value = store.game.currentShot; currentShot.value = newCurrentShot || 0;
} totalShot.value = newTotalShot || 0;
}, { immediate: true }); },
{ immediate: true }
);
// 监听 Pinia store 中 tips 变化,用于比赛恢复时同步提示文案(替代 uni.$emit 避免时序问题) // 监听 Pinia store 中 tips 变化,用于比赛恢复时同步提示文案(替代 uni.$emit 避免时序问题)
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决 onShow 早于 onMounted 导致 uni.$emit 事件丢失的问题) // 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决 onShow 早于 onMounted 导致 uni.$emit 事件丢失的问题)

View File

@@ -9,7 +9,7 @@ const props = defineProps({
}, },
total: { total: {
type: Number, type: Number,
default: 15, default: 12,
}, },
currentRound: { currentRound: {
type: String, type: String,
@@ -18,9 +18,9 @@ const props = defineProps({
}); });
const barColor = ref(""); const barColor = ref("");
const remain = ref(15); const remain = ref(12);
const timer = ref(null); const timer = ref(null);
const loading = ref(false); const loading = ref(true);
const transitionStyle = ref("all 1s linear"); const transitionStyle = ref("all 1s linear");
const currentTeam = ref(null); const currentTeam = ref(null);
const MIN_TICK_MS = 1; const MIN_TICK_MS = 1;

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue"; import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
import Container from "./components/Container.vue"; import Container from "./components/Container.vue";
import BattleHeader from "./components/BattleHeader.vue"; import BattleHeader from "./components/BattleHeader.vue";
import BowTarget from "./components/BowTarget.vue"; import BowTarget from "./components/BowTarget.vue";
@@ -15,7 +15,10 @@ import SModal from "./components/SModal.vue";
import { laserCloseAPI, getBattleAPI } from "@/apis"; import { laserCloseAPI, getBattleAPI } from "@/apis";
import { MESSAGETYPESV2 } from "@/constants"; import { MESSAGETYPESV2 } from "@/constants";
import { getDirectionText } from "@/util"; import { getDirectionText } from "@/util";
import audioManager from "@/audioManager"; import audioManager, {
AUDIO_INTERRUPTION_BEGIN_EVENT,
AUDIO_INTERRUPTION_END_EVENT,
} from "@/audioManager";
import useStore from "@/store"; import useStore from "@/store";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@@ -38,6 +41,7 @@ const AUDIO_TIMEOUT_PER_KEY = 2600;
const AUDIO_TIMEOUT_MAX = 12000; const AUDIO_TIMEOUT_MAX = 12000;
const BATTLE_CANCEL_RETURN_DELAY = 2000; const BATTLE_CANCEL_RETURN_DELAY = 2000;
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks";
const PROGRESS_ZERO_EVENT = "team-battle-progress-zero"; const PROGRESS_ZERO_EVENT = "team-battle-progress-zero";
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready"; const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
@@ -104,6 +108,21 @@ watch(online, (newVal, oldVal) => {
}); });
// 统一把秒级或毫秒级时间戳转成毫秒,方便和本机时间比较。 // 统一把秒级或毫秒级时间戳转成毫秒,方便和本机时间比较。
function loadXRingStreaks() {
const cached = uni.getStorageSync(X_RING_STREAKS_KEY);
xRingStreaks.value =
cached && typeof cached === "object" && !Array.isArray(cached) ? cached : {};
}
function saveXRingStreaks() {
uni.setStorageSync(X_RING_STREAKS_KEY, xRingStreaks.value);
}
function clearXRingStreaks() {
xRingStreaks.value = {};
uni.removeStorageSync(X_RING_STREAKS_KEY);
}
function normalizeTimestamp(value) { function normalizeTimestamp(value) {
const numberValue = Number(value || 0); const numberValue = Number(value || 0);
if (!numberValue) return 0; if (!numberValue) return 0;
@@ -210,11 +229,19 @@ function waitForRoundTipClosed(isFinal) {
}); });
} }
function handleRoundTipAutoClose() { function closeRoundTip() {
showRoundTip.value = false; showRoundTip.value = false;
clearRoundTipWaiters(); clearRoundTipWaiters();
} }
function cancelRoundTipDisplay() {
closeRoundTip();
}
function handleRoundTipAutoClose() {
closeRoundTip();
}
function markProgressDeadline(countdown, delayMs = 0) { function markProgressDeadline(countdown, delayMs = 0) {
if (!countdown?.value || !countdown?.durationMs) { if (!countdown?.value || !countdown?.durationMs) {
progressDeadlineAt = 0; progressDeadlineAt = 0;
@@ -280,7 +307,7 @@ function invalidateBattleQueue({ stopAudio = false, stopProgress = false } = {})
clearAudioWaiters(); clearAudioWaiters();
progressDeadlineAt = 0; progressDeadlineAt = 0;
clearProgressZeroWaiters(); clearProgressZeroWaiters();
clearRoundTipWaiters(); cancelRoundTipDisplay();
if (stopAudio) audioManager.stopAll(); if (stopAudio) audioManager.stopAll();
if (stopProgress) uni.$emit("update-remain", { stop: true }); if (stopProgress) uni.$emit("update-remain", { stop: true });
} }
@@ -402,6 +429,20 @@ function onAudioEnded(key) {
}); });
} }
function handleBattleCovered() {
if (pendingRestoreTimer) {
clearTimeout(pendingRestoreTimer);
pendingRestoreTimer = null;
}
hideRestoreLoading();
pendingRoundAudio = false;
invalidateBattleQueue({ stopAudio: true, stopProgress: true });
}
function handleBattleRecovered() {
scheduleRestoreLatestBattle();
}
// 队伍信息优先用接口返回值;接口缺失时使用本地缓存,避免重进页面时头像为空。 // 队伍信息优先用接口返回值;接口缺失时使用本地缓存,避免重进页面时头像为空。
function loadTeamPlayers(teamInfo, storageKey) { function loadTeamPlayers(teamInfo, storageKey) {
if (Array.isArray(teamInfo?.players)) return [...teamInfo.players]; if (Array.isArray(teamInfo?.players)) return [...teamInfo.players];
@@ -425,6 +466,44 @@ function updateGoldenRound(battleInfo) {
goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0)); goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0));
} }
// Restore an info snapshot whose eventType points at the NewRound phase.
function getRestorePrevRound(battleInfo) {
const currentRoundValue = Number(battleInfo?.current?.round || 0);
if (currentRoundValue > 1) return currentRoundValue - 1;
const rounds = Array.isArray(battleInfo?.rounds) ? battleInfo.rounds : [];
return Math.max(1, rounds.length || 1);
}
function applyRestoreNewRoundSnapshot(battleInfo) {
const prevRound = getRestorePrevRound(battleInfo);
start.value = true;
showRoundTip.value = false;
scores.value = [];
blueScores.value = [];
currentRound.value = prevRound;
if (battleInfo.current?.goldRound) {
store.updateShotInfo(0, 0);
currentBluePoint.value = battleInfo.teams?.[1]?.score ?? 0;
currentRedPoint.value = battleInfo.teams?.[2]?.score ?? 0;
} else {
const latestRound = battleInfo.rounds?.[prevRound - 1];
if (battleInfo?.shootNumber) {
store.updateShotInfo(0, battleInfo.shootNumber);
}
if (latestRound) {
currentBluePoint.value = latestRound.scores?.[1]?.score ?? 0;
currentRedPoint.value = latestRound.scores?.[2]?.score ?? 0;
} else {
currentBluePoint.value = 0;
currentRedPoint.value = 0;
}
}
return prevRound;
}
// 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。 // 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。
function applyBattleBase(battleInfo) { function applyBattleBase(battleInfo) {
if (!battleInfo) return; if (!battleInfo) return;
@@ -633,7 +712,7 @@ function applyReadyState(battleInfo) {
updateTips(""); updateTips("");
progressDeadlineAt = 0; progressDeadlineAt = 0;
clearProgressZeroWaiters(); clearProgressZeroWaiters();
clearRoundTipWaiters(); cancelRoundTipDisplay();
const createTime = normalizeTimestamp(battleInfo?.createTime || Date.now()); const createTime = normalizeTimestamp(battleInfo?.createTime || Date.now());
const readyElapsed = (Date.now() - createTime) / 1000; const readyElapsed = (Date.now() - createTime) / 1000;
@@ -645,7 +724,7 @@ function applyReadyState(battleInfo) {
} }
// 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。 // 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。
function applyBattleSnapshot(battleInfo, { restore = false } = {}) { function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) {
// 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。 // 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。
applyBattleBase(battleInfo); applyBattleBase(battleInfo);
if (battleInfo.status === 0) { if (battleInfo.status === 0) {
@@ -655,6 +734,12 @@ function applyBattleSnapshot(battleInfo, { restore = false } = {}) {
start.value = true; start.value = true;
showRoundTip.value = false; showRoundTip.value = false;
if (restore && restoreEventType === MESSAGETYPESV2.NewRound) {
applyRestoreNewRoundSnapshot(battleInfo);
return;
}
updateShotInfo(battleInfo); updateShotInfo(battleInfo);
updateCurrentRoundScores(battleInfo); updateCurrentRoundScores(battleInfo);
@@ -682,6 +767,7 @@ function applyBattleSnapshot(battleInfo, { restore = false } = {}) {
// 开局任务:切换到正式比赛态,并播报“比赛开始”。 // 开局任务:切换到正式比赛态,并播报“比赛开始”。
async function runBattleStartTask(task, runId) { async function runBattleStartTask(task, runId) {
// 开赛任务只负责切换正式态并播“比赛开始”,后续进入队列顺序。 // 开赛任务只负责切换正式态并播“比赛开始”,后续进入队列顺序。
clearXRingStreaks();
applyBattleBase(task.message); applyBattleBase(task.message);
start.value = true; start.value = true;
pendingRoundAudio = true; pendingRoundAudio = true;
@@ -694,12 +780,20 @@ async function runBattleStartTask(task, runId) {
async function runToSomeoneShootTask(task, runId) { async function runToSomeoneShootTask(task, runId) {
// 新射手任务:先等上一轮倒计时归零,再更新展示、播轮次/射手语音、启动倒计时。 // 新射手任务:先等上一轮倒计时归零,再更新展示、播轮次/射手语音、启动倒计时。
const battleInfo = task.message; const battleInfo = task.message;
const shouldEnterImmediately = showRoundTip.value;
if (shouldEnterImmediately) {
cancelRoundTipDisplay();
progressDeadlineAt = 0;
clearProgressZeroWaiters();
} else {
await waitForProgressZero(); await waitForProgressZero();
}
if (!isQueueAlive(runId)) return; if (!isQueueAlive(runId)) return;
applyBattleBase(battleInfo); applyBattleBase(battleInfo);
start.value = true; start.value = true;
showRoundTip.value = false; hideRestoreLoading();
cancelRoundTipDisplay();
updateShotInfo(battleInfo); updateShotInfo(battleInfo);
const current = battleInfo.current || {}; const current = battleInfo.current || {};
@@ -752,12 +846,17 @@ function updateXRingStreak(shooterId, isXRing) {
const id = String(shooterId); const id = String(shooterId);
if (!isXRing) { if (!isXRing) {
xRingStreaks.value[id] = 0; xRingStreaks.value[id] = 0;
saveXRingStreaks();
return false; return false;
} }
xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1; xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1;
if (xRingStreaks.value[id] < 3) return false; if (xRingStreaks.value[id] < 3) {
saveXRingStreaks();
return false;
}
xRingStreaks.value[id] = 0; xRingStreaks.value[id] = 0;
saveXRingStreaks();
return true; return true;
} }
@@ -801,7 +900,7 @@ async function runShootResultTask(task) {
async function runNewRoundTask(task, runId) { async function runNewRoundTask(task, runId) {
// 新回合提示要故意延后一点,避免和上一箭结果展示抢先后顺序。 // 新回合提示要故意延后一点,避免和上一箭结果展示抢先后顺序。
const battleInfo = task.message; const battleInfo = task.message;
const prevRound = currentRound.value; const prevRound = task.restorePrevRound || currentRound.value;
await new Promise((resolve) => setTimeout(resolve, ROUND_TIP_DELAY)); await new Promise((resolve) => setTimeout(resolve, ROUND_TIP_DELAY));
if (!isQueueAlive(runId)) return; if (!isQueueAlive(runId)) return;
@@ -837,9 +936,6 @@ async function runNewRoundTask(task, runId) {
currentRedPoint.value = 0; currentRedPoint.value = 0;
} }
pendingRoundAudio = true; pendingRoundAudio = true;
await waitForRoundTipClosed(!!battleInfo.current?.goldRound);
if (!isQueueAlive(runId)) return;
showRoundTip.value = false;
} }
// 终局任务:播放结束语音后,根据状态跳结果页或返回上一页。 // 终局任务:播放结束语音后,根据状态跳结果页或返回上一页。
@@ -847,6 +943,7 @@ async function runBattleEndTask(task, runId) {
const battleInfo = task.message; const battleInfo = task.message;
applyBattleBase(battleInfo); applyBattleBase(battleInfo);
battleEnded = true; battleEnded = true;
clearXRingStreaks();
matchStatus.value = battleInfo.status; matchStatus.value = battleInfo.status;
if (battleInfo.status === 4) { if (battleInfo.status === 4) {
showRoundTip.value = true; showRoundTip.value = true;
@@ -907,15 +1004,44 @@ async function restoreLatestBattle() {
latestAppliedServerTime.value = Math.max(latestAppliedServerTime.value, snapshotTime); latestAppliedServerTime.value = Math.max(latestAppliedServerTime.value, snapshotTime);
} }
const restoreEventType = Number(result?.eventType || 0);
if (result.status === 2) { if (result.status === 2) {
clearXRingStreaks();
hideRestoreLoading(); hideRestoreLoading();
uni.redirectTo({ uni.redirectTo({
url: `/pages/friend-battle-result?battleId=${result.matchId}`, url: `/pages/friend-battle-result?battleId=${result.matchId}`,
}); });
return; return;
} }
if (result.status === 4) {
clearXRingStreaks();
}
applyBattleSnapshot(result, { restore: true }); if (restoreEventType === MESSAGETYPESV2.NewRound) {
const prevRound = getRestorePrevRound(result);
const restoreMessage = { ...result, type: MESSAGETYPESV2.NewRound };
const restoreKey = getMessageKey(restoreMessage);
applyBattleSnapshot(result, { restore: true, restoreEventType });
if (!handledMessageKeys.has(restoreKey) && !queuedMessageKeys.has(restoreKey)) {
queuedMessageKeys.add(restoreKey);
battleQueue.value.unshift({
message: result,
type: MESSAGETYPESV2.NewRound,
key: restoreKey,
serverTime: snapshotTime,
receivedAt: Date.now(),
order: ++queueOrder,
restorePrevRound: prevRound,
});
}
runBattleQueue();
return;
}
applyBattleSnapshot(result, { restore: true, restoreEventType });
runBattleQueue(); runBattleQueue();
} }
@@ -967,7 +1093,7 @@ onLoad((options) => {
shootTimeTotal.value = DEFAULT_SHOOT_TIME; shootTimeTotal.value = DEFAULT_SHOOT_TIME;
showOfflineModal.value = false; showOfflineModal.value = false;
hideRestoreLoading(); hideRestoreLoading();
xRingStreaks.value = {}; loadXRingStreaks();
queueGeneration += 1; queueGeneration += 1;
battleQueue.value = []; battleQueue.value = [];
queueRunning.value = false; queueRunning.value = false;
@@ -981,7 +1107,7 @@ onLoad((options) => {
queuedMessageKeys.clear(); queuedMessageKeys.clear();
progressDeadlineAt = 0; progressDeadlineAt = 0;
clearProgressZeroWaiters(); clearProgressZeroWaiters();
clearRoundTipWaiters(); cancelRoundTipDisplay();
store.updateShotInfo(0, 0); store.updateShotInfo(0, 0);
store.updateTips(""); store.updateTips("");
latestShotFlash.value = null; latestShotFlash.value = null;
@@ -995,6 +1121,8 @@ onMounted(async () => {
}); });
uni.$on("socket-inbox", onReceiveMessage); uni.$on("socket-inbox", onReceiveMessage);
uni.$on("audioEnded", onAudioEnded); uni.$on("audioEnded", onAudioEnded);
uni.$on(AUDIO_INTERRUPTION_BEGIN_EVENT, handleBattleCovered);
uni.$on(AUDIO_INTERRUPTION_END_EVENT, handleBattleRecovered);
uni.$on(PROGRESS_ZERO_EVENT, onProgressZero); uni.$on(PROGRESS_ZERO_EVENT, onProgressZero);
uni.$on(COUNTDOWN_READY_EVENT, hideRestoreLoading); uni.$on(COUNTDOWN_READY_EVENT, hideRestoreLoading);
await laserCloseAPI(); await laserCloseAPI();
@@ -1015,9 +1143,17 @@ onBeforeUnmount(() => {
} }
hideRestoreLoading(); hideRestoreLoading();
invalidateBattleQueue({ stopAudio: true, stopProgress: true }); invalidateBattleQueue({ stopAudio: true, stopProgress: true });
console.log('onBeforeUnmount', '页面卸载前')
audioManager.stopAll(); audioManager.stopAll();
uni.$off(AUDIO_INTERRUPTION_BEGIN_EVENT, handleBattleCovered);
uni.$off(AUDIO_INTERRUPTION_END_EVENT, handleBattleRecovered);
}); });
onHide(()=>{
console.log('onHide', '页面大退')
handleBattleCovered();
})
// 每次回到前台都重新拉最新比赛快照,确保画面与后端一致。 // 每次回到前台都重新拉最新比赛快照,确保画面与后端一致。
onShow(() => { onShow(() => {
console.log('onshow') console.log('onshow')

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/static/ota/new-ver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src/static/ota/ota-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/static/ota/ota-ver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
src/static/ota/wifi1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
src/static/ota/wifi2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

BIN
src/static/sicon/cancel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/static/sicon/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

BIN
src/static/sicon/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

BIN
src/static/sicon/eye-on.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

BIN
src/static/sicon/pwd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
src/static/sicon/tick.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
src/static/sicon/wifi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B