6 Commits
Author SHA1 Message Date
chenlimao 75cfcd66ee fix: OTA轮询次数改为15次 2026-08-18 13:57:49 +08:00
chenlimao 7955b37ec5 fix: 修复首页更新卡在更新中弹窗问题 2026-08-18 11:48:20 +08:00
chenlimao 9e6ad4cf50 feat: OTA 首页弹窗优化 2026-08-18 10:30:16 +08:00
chenlimao 6c55d32f90 chore: 依赖锁提交 2026-08-17 18:02:12 +08:00
zhangyi 63fb4ae85a update:优化性能问题 2026-08-17 10:11:28 +08:00
zhangyi 2628a17b3f update: 新增设备充电通知 2026-08-14 17:59:11 +08:00
28 changed files with 886 additions and 2237 deletions
+7
View File
@@ -28,6 +28,7 @@
const { const {
updateUser, updateUser,
updateOnline, updateOnline,
showDeviceChargingDialog,
clearSessionState, clearSessionState,
clearDevice clearDevice
} = store; } = store;
@@ -82,6 +83,10 @@
uni.setStorageSync("calibration", false); uni.setStorageSync("calibration", false);
} }
function onDeviceCharging() {
showDeviceChargingDialog();
}
function onDeviceShoot() { function onDeviceShoot() {
// audioManager.play("射箭声音") // audioManager.play("射箭声音")
} }
@@ -119,6 +124,7 @@
uni.$on("update-online", emitUpdateOnline); uni.$on("update-online", emitUpdateOnline);
uni.$on("session-kicked-out", onSessionKickedOut); uni.$on("session-kicked-out", onSessionKickedOut);
uni.$on("device-bind-invalid", onDeviceBindInvalid); uni.$on("device-bind-invalid", onDeviceBindInvalid);
uni.$on("device-charging", onDeviceCharging);
if (typeof uni.offNetworkStatusChange === "function") { if (typeof uni.offNetworkStatusChange === "function") {
uni.offNetworkStatusChange(onNetworkStatusChange); uni.offNetworkStatusChange(onNetworkStatusChange);
} }
@@ -144,6 +150,7 @@
uni.$off("update-online", emitUpdateOnline); uni.$off("update-online", emitUpdateOnline);
uni.$off("session-kicked-out", onSessionKickedOut); uni.$off("session-kicked-out", onSessionKickedOut);
uni.$off("device-bind-invalid", onDeviceBindInvalid); uni.$off("device-bind-invalid", onDeviceBindInvalid);
uni.$off("device-charging", onDeviceCharging);
if (typeof uni.offNetworkStatusChange === "function") { if (typeof uni.offNetworkStatusChange === "function") {
uni.offNetworkStatusChange(onNetworkStatusChange); uni.offNetworkStatusChange(onNetworkStatusChange);
} }
+121 -22
View File
@@ -112,6 +112,8 @@ export const audioFils = {
const AUDIO_LOAD_TIMEOUT_MS = 5000; const AUDIO_LOAD_TIMEOUT_MS = 5000;
const AUDIO_WARM_CONCURRENCY = 3; const AUDIO_WARM_CONCURRENCY = 3;
const AUDIO_WARM_RETRIES = 1; const AUDIO_WARM_RETRIES = 1;
const AUDIO_INSTANCE_LIMIT = 24;
const AUDIO_QUEUE_COMPACT_THRESHOLD = 12;
const AUDIO_WARM_PRIORITY_KEYS = [ const AUDIO_WARM_PRIORITY_KEYS = [
"点击按钮", "点击按钮",
"设备连接已断开", "设备连接已断开",
@@ -162,6 +164,8 @@ class AudioManager {
this.loadingKeyPromises = new Map(); this.loadingKeyPromises = new Map();
this.pendingPlayKeys = new Set(); this.pendingPlayKeys = new Set();
this.cacheWritePromises = new Map(); this.cacheWritePromises = new Map();
this.audioLastUsedAt = new Map();
this.warmPriorityPromise = null;
this.warmAllPromise = null; this.warmAllPromise = null;
// 连续播放队列相关属性 // 连续播放队列相关属性
@@ -253,14 +257,74 @@ class AudioManager {
} }
getWarmupKeys() { getWarmupKeys() {
return Array.from( return Array.from(new Set(AUDIO_WARM_PRIORITY_KEYS));
new Set([...AUDIO_WARM_PRIORITY_KEYS, ...Object.keys(audioFils)]) }
);
getAllAudioKeys() {
return Object.keys(audioFils);
}
touchAudioKey(key) {
this.audioLastUsedAt.set(key, Date.now());
}
destroyAudioInstance(key) {
const audio = this.audioMap.get(key);
if (!audio) return false;
this.clearPlayWatchdog(key);
this.audioMap.delete(key);
this.readyMap.delete(key);
this.allowPlayMap.delete(key);
this.audioLastUsedAt.delete(key);
try {
audio.destroy();
} catch (_) {}
// destroy 可能同步触发 onStop,最后再清一次对应状态。
this.allowPlayMap.delete(key);
return true;
}
pruneAudioInstances(preserveKey) {
while (this.audioMap.size >= AUDIO_INSTANCE_LIMIT) {
const candidate = Array.from(this.audioMap.keys())
.filter(
(key) =>
key !== preserveKey &&
key !== this.currentPlayingKey &&
!this.loadingKeyPromises.has(key)
)
.sort(
(left, right) =>
(this.audioLastUsedAt.get(left) || 0) -
(this.audioLastUsedAt.get(right) || 0)
)[0];
if (!candidate || !this.destroyAudioInstance(candidate)) return;
}
}
compactSequenceQueue(force = false) {
if (!this.isSequenceRunning || this.sequenceIndex <= 0) return;
if (!force && this.sequenceIndex < AUDIO_QUEUE_COMPACT_THRESHOLD) return;
this.sequenceQueue = this.sequenceQueue.slice(this.sequenceIndex);
this.sequenceIndex = 0;
}
getRuntimeStats() {
return {
audioInstanceCount: this.audioMap.size,
loadingAudioCount: this.loadingKeyPromises.size,
pendingPlayCount: this.pendingPlayKeys.size,
sequenceQueueLength: this.isSequenceRunning
? Math.max(this.sequenceQueue.length - this.sequenceIndex, 0)
: 0,
};
} }
ensureAudio(key) { ensureAudio(key) {
if (!audioFils[key]) return Promise.resolve(false); if (!audioFils[key]) return Promise.resolve(false);
if (this.readyMap.get(key) && this.audioMap.has(key)) { if (this.readyMap.get(key) && this.audioMap.has(key)) {
this.touchAudioKey(key);
return Promise.resolve(true); return Promise.resolve(true);
} }
@@ -312,23 +376,39 @@ class AudioManager {
return this.warmKeys(["点击按钮"], { concurrency: 1, retries: 1 }); return this.warmKeys(["点击按钮"], { concurrency: 1, retries: 1 });
} }
warmAll() { warmCommon() {
if (this.warmAllPromise) return this.warmAllPromise; if (this.warmPriorityPromise) return this.warmPriorityPromise;
debugLog("开始后台分级预热全部语音"); debugLog("开始后台预热常用语音");
this.isLoading = true; this.isLoading = true;
this.warmAllPromise = this.warmKeys(this.getWarmupKeys()) this.warmPriorityPromise = this.warmKeys(this.getWarmupKeys())
.catch((error) => { .catch((error) => {
debugLog("后台语音预热异常", error); debugLog("常用语音预热异常", error);
}) })
.finally(() => { .finally(() => {
this.isLoading = false; this.isLoading = false;
debugLog("后台语音预热结束", this.getLoadProgress()); debugLog("常用语音预热结束", this.getRuntimeStats());
});
return this.warmPriorityPromise;
}
warmAll() {
if (this.warmAllPromise) return this.warmAllPromise;
debugLog("音频测试页开始遍历全部语音");
this.isLoading = true;
this.warmAllPromise = this.warmKeys(this.getAllAudioKeys())
.catch((error) => {
debugLog("全部语音遍历异常", error);
})
.finally(() => {
this.isLoading = false;
debugLog("全部语音遍历结束", this.getRuntimeStats());
}); });
return this.warmAllPromise; return this.warmAllPromise;
} }
// 保留旧入口兼容测试页或其他历史调用,实际行为改为非阻塞后台预热。 // 音频测试页保留全量遍历;实例池会及时淘汰旧实例,避免同时常驻全部语音。
initAudios() { initAudios() {
return this.warmAll(); return this.warmAll();
} }
@@ -366,6 +446,7 @@ class AudioManager {
return; return;
} }
this.pruneAudioInstances(key);
const audio = uni.createInnerAudioContext(); const audio = uni.createInnerAudioContext();
audio.autoplay = false; audio.autoplay = false;
try { try {
@@ -377,6 +458,7 @@ class AudioManager {
} catch (_) {} } catch (_) {}
this.allowPlayMap.set(key, false); this.allowPlayMap.set(key, false);
audio.onPlay(() => { audio.onPlay(() => {
if (this.audioMap.get(key) !== audio) return;
if (!this.allowPlayMap.get(key)) { if (!this.allowPlayMap.get(key)) {
try { try {
audio.stop(); audio.stop();
@@ -414,6 +496,7 @@ class AudioManager {
} }
this.readyMap.set(key, false); this.readyMap.set(key, false);
this.audioMap.delete(key); this.audioMap.delete(key);
this.audioLastUsedAt.delete(key);
try { try {
audio.destroy(); audio.destroy();
} catch (_) {} } catch (_) {}
@@ -446,6 +529,7 @@ class AudioManager {
if (!isCurrentGeneration()) { if (!isCurrentGeneration()) {
clearLoadTimeout(); clearLoadTimeout();
this.audioMap.delete(key); this.audioMap.delete(key);
this.audioLastUsedAt.delete(key);
try { try {
audio.destroy(); audio.destroy();
} catch (_) {} } catch (_) {}
@@ -480,6 +564,7 @@ class AudioManager {
}); });
audio.onEnded(() => { audio.onEnded(() => {
if (this.audioMap.get(key) !== audio) return;
this.finishPlayback(key, { this.finishPlayback(key, {
advanceSequence: true, advanceSequence: true,
emitEnded: true, emitEnded: true,
@@ -487,10 +572,12 @@ class AudioManager {
}); });
audio.onStop(() => { audio.onStop(() => {
if (this.audioMap.get(key) !== audio) return;
this.finishPlayback(key); this.finishPlayback(key);
}); });
this.audioMap.set(key, audio); this.audioMap.set(key, audio);
this.touchAudioKey(key);
audio.src = realSrc; audio.src = realSrc;
}; };
@@ -700,14 +787,7 @@ class AudioManager {
const loadingPromise = this.loadingKeyPromises.get(key); const loadingPromise = this.loadingKeyPromises.get(key);
if (loadingPromise) return loadingPromise; if (loadingPromise) return loadingPromise;
this.clearPlayWatchdog(key); this.destroyAudioInstance(key);
const oldAudio = this.audioMap.get(key);
if (oldAudio) {
try {
oldAudio.destroy();
} catch (_) {}
}
this.audioMap.delete(key);
this.readyMap.set(key, false); this.readyMap.set(key, false);
return this.ensureAudio(key); return this.ensureAudio(key);
} }
@@ -754,7 +834,8 @@ class AudioManager {
// 不打断当前播放:把新的队列加入到序列中,等待当前播放结束后衔接 // 不打断当前播放:把新的队列加入到序列中,等待当前播放结束后衔接
if (this.currentPlayingKey) { if (this.currentPlayingKey) {
if (this.isSequenceRunning) { if (this.isSequenceRunning) {
// 已有序列在跑:直接追加 // 已有序列在跑:先移除已消费前缀,再追加待播语音。
this.compactSequenceQueue(true);
this.sequenceQueue = this.sequenceQueue.concat(queue); this.sequenceQueue = this.sequenceQueue.concat(queue);
} else { } else {
// 没有序列但当前有正在播放的:以当前为序列的起点 // 没有序列但当前有正在播放的:以当前为序列的起点
@@ -763,6 +844,17 @@ class AudioManager {
this.sequenceIndex = 0; this.sequenceIndex = 0;
// 不触发 _playSingle,等待当前音频自然结束后由 onAudioEnded 接管 // 不触发 _playSingle,等待当前音频自然结束后由 onAudioEnded 接管
} }
} else if (
this.isSequenceRunning &&
this.sequenceQueue[this.sequenceIndex]
) {
// 当前队首正在按需加载时只追加,不能覆盖已经排队的语音。
this.compactSequenceQueue(true);
this.sequenceQueue = this.sequenceQueue.concat(queue);
const pendingKey = this.sequenceQueue[this.sequenceIndex];
if (!this.pendingPlayKeys.has(pendingKey)) {
this._playSingle(pendingKey, false);
}
} else { } else {
// 当前没有播放:直接启动新的序列 // 当前没有播放:直接启动新的序列
this.sequenceQueue = queue; this.sequenceQueue = queue;
@@ -869,6 +961,7 @@ class AudioManager {
this.currentPlayingKey = key; this.currentPlayingKey = key;
this.lastPlayKey = key; this.lastPlayKey = key;
this.lastPlayAt = Date.now(); this.lastPlayAt = Date.now();
this.touchAudioKey(key);
this.startPlayWatchdog(key); this.startPlayWatchdog(key);
} else { } else {
debugLog(`音频 ${key} 尚未就绪,按需加载后播放...`); debugLog(`音频 ${key} 尚未就绪,按需加载后播放...`);
@@ -885,7 +978,8 @@ class AudioManager {
const nextIndex = this.sequenceIndex + 1; const nextIndex = this.sequenceIndex + 1;
if (nextIndex < this.sequenceQueue.length) { if (nextIndex < this.sequenceQueue.length) {
this.sequenceIndex = nextIndex; this.sequenceIndex = nextIndex;
const nextKey = this.sequenceQueue[nextIndex]; this.compactSequenceQueue();
const nextKey = this.sequenceQueue[this.sequenceIndex];
this._playSingle(nextKey, false); this._playSingle(nextKey, false);
} else { } else {
// 队列播放完成 // 队列播放完成
@@ -969,6 +1063,7 @@ class AudioManager {
reloadAll() { reloadAll() {
debugLog("执行 reloadAll: 重置音频实例并后台恢复"); debugLog("执行 reloadAll: 重置音频实例并后台恢复");
const shouldWarmAll = this.warmAllPromise !== null; const shouldWarmAll = this.warmAllPromise !== null;
const shouldWarmPriority = this.warmPriorityPromise !== null;
this.loadGeneration += 1; this.loadGeneration += 1;
// 1. 停止所有播放 // 1. 停止所有播放
@@ -981,6 +1076,7 @@ class AudioManager {
} catch (_) {} } catch (_) {}
} }
this.audioMap.clear(); this.audioMap.clear();
this.audioLastUsedAt.clear();
// 3. 重置状态 // 3. 重置状态
this.readyMap.clear(); this.readyMap.clear();
@@ -999,10 +1095,13 @@ class AudioManager {
// 4. 重置后台预热状态 // 4. 重置后台预热状态
this.isLoading = false; this.isLoading = false;
this.warmPriorityPromise = null;
this.warmAllPromise = null; this.warmAllPromise = null;
// 5. 之前已启动过全量预热则继续全量恢复,否则只恢复按钮音效。 // 5. 按中断前使用范围恢复,产品页不再全量创建语音实例。
return shouldWarmAll ? this.warmAll() : this.warmButton(); if (shouldWarmAll) return this.warmAll();
if (shouldWarmPriority) return this.warmCommon();
return this.warmButton();
} }
} }
+21 -5
View File
@@ -4,18 +4,34 @@ import { getDeviceBatteryAPI } from "@/apis";
const power = ref(0); const power = ref(0);
const timer = ref(null); const timer = ref(null);
let disposed = false;
let requestInFlight = false;
const refreshPower = async () => {
if (disposed || requestInFlight) return;
requestInFlight = true;
try {
const data = await getDeviceBatteryAPI();
if (!disposed) power.value = data.battery;
} catch (_) {
// 电量轮询失败时等待下一轮,避免产生未处理的 Promise 拒绝。
} finally {
requestInFlight = false;
}
};
onMounted(async () => { onMounted(async () => {
const data = await getDeviceBatteryAPI(); await refreshPower();
power.value = data.battery; if (disposed) return;
timer.value = setInterval(async () => { timer.value = setInterval(() => {
const data = await getDeviceBatteryAPI(); void refreshPower();
power.value = data.battery;
}, 1000 * 10); }, 1000 * 10);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true;
clearInterval(timer.value); clearInterval(timer.value);
timer.value = null;
}); });
</script> </script>
+2
View File
@@ -5,6 +5,7 @@ import AppBackground from "@/components/AppBackground.vue";
import Header from "@/components/Header.vue"; import Header from "@/components/Header.vue";
import ScreenHint from "@/components/ScreenHint.vue"; import ScreenHint from "@/components/ScreenHint.vue";
import BackToGame from "@/components/BackToGame.vue"; import BackToGame from "@/components/BackToGame.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis"; import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
import { capsuleHeight, debounce } from "@/util"; import { capsuleHeight, debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn"; import { returnToBattle } from "@/utils/matchReturn";
@@ -226,6 +227,7 @@ const goCalibration = async () => {
<text>{{ loadingText || "加载中..." }}</text> <text>{{ loadingText || "加载中..." }}</text>
</view> </view>
</view> </view>
<DeviceChargingDialog />
</view> </view>
</template> </template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import { storeToRefs } from "pinia";
import ModalDialog from "@/components/ModalDialog.vue";
import useStore from "@/store";
const chargingNotice =
"检测到智能弓箭已开始充电,为保护电池,设备即将自动关机。\n(注意:此时拔出充电线将会重启设备。)";
const store = useStore();
const { deviceChargingDialogVisible } = storeToRefs(store);
const { hideDeviceChargingDialog } = store;
</script>
<template>
<ModalDialog
:show="deviceChargingDialogVisible"
:content="chargingNotice"
:show-cancel="false"
confirm-text="我知道了"
:on-confirm="hideDeviceChargingDialog"
/>
</template>
+36 -16
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue"; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
rowCount: { rowCount: {
type: Number, type: Number,
@@ -29,31 +29,24 @@ const bgImages = [
"https://static.shelingxingqiu.com/shootmini/static/complete-light1.png", "https://static.shelingxingqiu.com/shootmini/static/complete-light1.png",
"https://static.shelingxingqiu.com/shootmini/static/complete-light2.png", "https://static.shelingxingqiu.com/shootmini/static/complete-light2.png",
]; ];
const bgIndex = ref(0);
watch( watch(
() => props.total, () => props.total,
(newValue) => { (newValue) => {
items.value = new Array(newValue).fill(9); items.value = new Array(newValue).fill(9);
} }
); );
const timer = ref(null);
onMounted(() => {
timer.value = setInterval(() => {
bgIndex.value = bgIndex.value === 0 ? 1 : 0;
}, 200);
});
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value);
}
});
</script> </script>
<template> <template>
<view class="container"> <view class="container">
<template v-if="total > 0 && arrows.length === total && completeEffect">
<image <image
v-if="total > 0 && arrows.length === total && completeEffect" v-for="(image, index) in bgImages"
:src="bgImages[bgIndex]" :key="image"
class="complete-light" :src="image"
:class="[
'complete-light',
index === 0 ? 'complete-light--first' : 'complete-light--second',
]"
:style="{ :style="{
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${ width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24)) (100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
@@ -65,6 +58,7 @@ onBeforeUnmount(() => {
top: `${total === 12 ? -2 : -3}vw`, top: `${total === 12 ? -2 : -3}vw`,
}" }"
/> />
</template>
<view <view
v-for="(_, index) in items" v-for="(_, index) in items"
:key="index" :key="index"
@@ -121,4 +115,30 @@ onBeforeUnmount(() => {
.complete-light { .complete-light {
position: absolute; position: absolute;
} }
.complete-light--first {
animation: complete-light-first 400ms steps(1, end) infinite;
}
.complete-light--second {
animation: complete-light-second 400ms steps(1, end) infinite;
}
@keyframes complete-light-first {
0%,
49.9% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes complete-light-second {
0%,
49.9% {
opacity: 0;
}
50%,
100% {
opacity: 1;
}
}
</style> </style>
+6 -1
View File
@@ -9,11 +9,13 @@ const props = defineProps({
const show = ref(false); const show = ref(false);
const count = ref(props.countdown); const count = ref(props.countdown);
const timer = ref(null); const timer = ref(null);
const showTimer = ref(null);
const updateTimer = (value) => { const updateTimer = (value) => {
count.value = Math.round(value); count.value = Math.round(value);
}; };
onMounted(() => { onMounted(() => {
setTimeout(() => { showTimer.value = setTimeout(() => {
showTimer.value = null;
show.value = true; show.value = true;
timer.value = setInterval(() => { timer.value = setInterval(() => {
if (count.value === 0) { if (count.value === 0) {
@@ -27,7 +29,10 @@ onMounted(() => {
uni.$on("update-timer", updateTimer); uni.$on("update-timer", updateTimer);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (showTimer.value) clearTimeout(showTimer.value);
showTimer.value = null;
if (timer.value) clearInterval(timer.value); if (timer.value) clearInterval(timer.value);
timer.value = null;
uni.$off("update-timer", updateTimer); uni.$off("update-timer", updateTimer);
}); });
</script> </script>
+23 -1
View File
@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
const MAX_VISIBLE_SCORE_CARDS = 60;
const props = defineProps({ const props = defineProps({
arrows: { arrows: {
type: Array, type: Array,
@@ -32,8 +34,16 @@ const isFailed = (arrow = {}) => {
return arrow.ok !== true; return arrow.ok !== true;
}; };
const hiddenScoreCount = computed(() =>
props.recordMode
? 0
: Math.max(props.arrows.length - MAX_VISIBLE_SCORE_CARDS, 0)
);
const displayArrows = computed(() => { const displayArrows = computed(() => {
const list = [...props.arrows]; const list = props.recordMode
? [...props.arrows]
: props.arrows.slice(-MAX_VISIBLE_SCORE_CARDS);
// total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。 // total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。
if (!props.recordMode) list.push(null); if (!props.recordMode) list.push(null);
return list; return list;
@@ -42,6 +52,10 @@ const displayArrows = computed(() => {
<template> <template>
<view v-if="displayArrows.length" class="score-panel"> <view v-if="displayArrows.length" class="score-panel">
<text v-if="hiddenScoreCount" class="score-window-tip">
已省略前 {{ hiddenScoreCount }} 支,仅显示最近
{{ MAX_VISIBLE_SCORE_CARDS }} 支
</text>
<view class="score-grid"> <view class="score-grid">
<view <view
v-for="(arrow, index) in displayArrows" v-for="(arrow, index) in displayArrows"
@@ -90,6 +104,14 @@ const displayArrows = computed(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.score-window-tip {
display: block;
margin-bottom: 18rpx;
color: rgba(255, 255, 255, 0.6);
font-size: 22rpx;
line-height: 1.4;
}
.score-card { .score-card {
position: relative; position: relative;
width: 100rpx; width: 100rpx;
+1
View File
@@ -29,6 +29,7 @@ export const MESSAGETYPES = {
DeviceOnline: 4168086626, DeviceOnline: 4168086626,
DeviceOffline: 4168086627, DeviceOffline: 4168086627,
SomeoneIsReady: 4168086628, SomeoneIsReady: 4168086628,
DeviceCharging: 4168086631, // 设备充电中
}; };
export const MESSAGETYPESV2 = { export const MESSAGETYPESV2 = {
+28 -1
View File
@@ -41,6 +41,27 @@ const LIVENESS_TIMEOUT_MS = 30000;
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000]; const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000];
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
const MATCH_STATUS_HALF_REST = 3; const MATCH_STATUS_HALF_REST = 3;
const ENABLE_REALTIME_MESSAGE_LOG = (() => {
try {
return uni.getAccountInfoSync().miniProgram.envVersion !== "release";
} catch (_) {
return false;
}
})();
function getServerMessageLogSummary(message = {}) {
const practiceInfo = message.practice_info || message.practiceInfo || {};
const matchInfo = message.match_info || message.matchInfo || {};
return {
type: message.type,
matchId: normalizeId(pickField(message, "matchId", "match_id")),
sequence: message.sequence,
practiceDetailCount: Array.isArray(practiceInfo.details)
? practiceInfo.details.length
: 0,
roundCount: Array.isArray(matchInfo.rounds) ? matchInfo.rounds.length : 0,
};
}
// 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。 // 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。
const BUSINESS_TYPE_BY_SERVER_TYPE = { const BUSINESS_TYPE_BY_SERVER_TYPE = {
@@ -785,7 +806,13 @@ function handleMessage(data) {
} }
const typeName = getServerMessageTypeName(message.type); const typeName = getServerMessageTypeName(message.type);
console.log("收到比赛服 WebSocket 消息", typeName, message); if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log(
"收到比赛服 WebSocket 消息",
typeName,
getServerMessageLogSummary(message)
);
}
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id")); const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
if (message.type === ServerMessageType.SERVER_MSG_SYNC_PRACTICE_INFO) { if (message.type === ServerMessageType.SERVER_MSG_SYNC_PRACTICE_INFO) {
+2
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import Avatar from "@/components/Avatar.vue"; import Avatar from "@/components/Avatar.vue";
import UserUpgrade from "@/components/UserUpgrade.vue"; import UserUpgrade from "@/components/UserUpgrade.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import { getBattleAPI } from "@/apis"; import { getBattleAPI } from "@/apis";
import { topThreeColors, getBattleResultTips } from "@/constants"; import { topThreeColors, getBattleResultTips } from "@/constants";
import audioManager from "@/audioManager"; import audioManager from "@/audioManager";
@@ -287,6 +288,7 @@ const checkBowData = () => {
<view @click="exit">返回</view> <view @click="exit">返回</view>
</view> </view>
<UserUpgrade /> <UserUpgrade />
<DeviceChargingDialog />
</view> </view>
</template> </template>
+2
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import SButton from "@/components/SButton.vue"; import SButton from "@/components/SButton.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import { capsuleHeight } from "@/util"; import { capsuleHeight } from "@/util";
@@ -55,6 +56,7 @@ const onScrollView = (e) => {
/> />
</view> </view>
</scroll-view> </scroll-view>
<DeviceChargingDialog />
</view> </view>
</template> </template>
+1 -1
View File
@@ -195,7 +195,7 @@ const onClickShare = debounce(async () => {
}); });
onMounted(() => { onMounted(() => {
void audioManager.warmAll(); void audioManager.warmCommon();
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: true, keepScreenOn: true,
}); });
+95 -24
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {onMounted, onUnmounted, ref} from "vue"; import {onMounted, onUnmounted, ref, watch} from "vue";
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app"; import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
import Container from "@/components/Container.vue"; import Container from "@/components/Container.vue";
import AppFooter from "@/components/AppFooter.vue"; import AppFooter from "@/components/AppFooter.vue";
@@ -52,20 +52,41 @@ const otaInfo = ref({
forceUpdate: false, forceUpdate: false,
}); });
const isStartingOta = ref(false); const isStartingOta = ref(false);
let isCheckingOta = false;
let otaCheckQueuedForOnline = false;
let otaProgressTimer = null; let otaProgressTimer = null;
let otaStatusTimer = null; let otaStatusTimer = null;
let otaTimeoutTimer = null; let otaTimeoutTimer = null;
let otaResultTimer = null;
let otaStatusPollCount = 0;
let otaUpdateRunId = 0;
// 首页 OTA 轮询采用请求次数和总时长双重兜底,避免更新中状态长期卡住。
const OTA_TASK_STATUS_POLL_INTERVAL = 2000;
const OTA_TASK_STATUS_MAX_POLL_COUNT = 15;
const OTA_TASK_STATUS_TIMEOUT = 30000;
// 清理首页 OTA 更新定时器,避免弹窗关闭或页面卸载后继续轮询。 // 清理首页 OTA 更新定时器,避免弹窗关闭或页面卸载后继续轮询。
const clearOtaUpdateTimers = () => { const clearOtaUpdateTimers = () => {
clearInterval(otaProgressTimer); clearInterval(otaProgressTimer);
clearTimeout(otaStatusTimer); clearTimeout(otaStatusTimer);
clearTimeout(otaTimeoutTimer); clearTimeout(otaTimeoutTimer);
clearTimeout(otaResultTimer);
otaProgressTimer = null; otaProgressTimer = null;
otaStatusTimer = null; otaStatusTimer = null;
otaTimeoutTimer = null; otaTimeoutTimer = null;
otaResultTimer = null;
otaStatusPollCount = 0;
}; };
// 使当前 OTA 运行失效,确保旧请求返回后不会继续轮询或覆盖新状态。
const invalidateOtaUpdateRun = () => {
otaUpdateRunId += 1;
clearOtaUpdateTimers();
};
const isOtaUpdateRunActive = (runId) =>
runId === otaUpdateRunId && otaState.value === "update_progress";
// 启动首页 OTA 本地进度动画,最终成功失败以后端任务状态为准。 // 启动首页 OTA 本地进度动画,最终成功失败以后端任务状态为准。
const startOtaProgressAnimation = () => { const startOtaProgressAnimation = () => {
clearInterval(otaProgressTimer); clearInterval(otaProgressTimer);
@@ -91,13 +112,25 @@ const applyOtaVersionInfo = (versionInfo) => {
// 检查当前设备盒子是否存在可升级版本。 // 检查当前设备盒子是否存在可升级版本。
const checkOtaUpdate = async () => { const checkOtaUpdate = async () => {
if (isCheckingOta || otaVisible.value) return;
isCheckingOta = true;
try {
let deviceStatus;
try {
deviceStatus = await getDeviceBatteryAPI();
} catch (err) {
return;
}
if (deviceStatus?.online !== true || otaVisible.value) return;
let versionInfo; let versionInfo;
try { try {
versionInfo = await getHardwareBoxVersionAPI(); versionInfo = await getHardwareBoxVersionAPI();
} catch (err) { } catch (err) {
return; return;
} }
if (!versionInfo?.needUpdate) return; if (!versionInfo?.needUpdate || otaVisible.value) return;
applyOtaVersionInfo(versionInfo); applyOtaVersionInfo(versionInfo);
const dismissedAt = uni.getStorageSync("ota_dismissed_at"); const dismissedAt = uni.getStorageSync("ota_dismissed_at");
@@ -105,8 +138,26 @@ const checkOtaUpdate = async () => {
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return; if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
otaState.value = "new_version"; otaState.value = "new_version";
otaVisible.value = true; otaVisible.value = true;
} finally {
isCheckingOta = false;
const shouldRecheckForOnline = otaCheckQueuedForOnline;
otaCheckQueuedForOnline = false;
if (shouldRecheckForOnline && !otaVisible.value) {
void checkOtaUpdate();
}
}
}; };
// 设备由 WS 通知上线时补查 OTA;已显示任何 OTA 弹窗时保留当前流程和结果。
watch(online, (nextOnline, previousOnline) => {
if (previousOnline !== false || nextOnline !== true || otaVisible.value) return;
if (isCheckingOta) {
otaCheckQueuedForOnline = true;
return;
}
void checkOtaUpdate();
});
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。 // 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
const getOtaWifiUrl = () => { const getOtaWifiUrl = () => {
const { versionNumber, resourceUrl } = otaInfo.value; const { versionNumber, resourceUrl } = otaInfo.value;
@@ -125,61 +176,78 @@ const handleOtaDismiss = () => {
}; };
// 将首页 OTA 直连更新流程标记为失败。 // 将首页 OTA 直连更新流程标记为失败。
const failHomeOtaUpdate = () => { const failHomeOtaUpdate = (runId) => {
clearOtaUpdateTimers(); if (!isOtaUpdateRunActive(runId)) return;
invalidateOtaUpdateRun();
isStartingOta.value = false; isStartingOta.value = false;
otaState.value = "update_failure"; otaState.value = "update_failure";
otaVisible.value = true; otaVisible.value = true;
}; };
// 将首页 OTA 直连更新流程标记为成功。 // 将首页 OTA 直连更新流程标记为成功。
const completeHomeOtaUpdate = () => { const completeHomeOtaUpdate = (runId) => {
clearOtaUpdateTimers(); if (!isOtaUpdateRunActive(runId)) return;
invalidateOtaUpdateRun();
isStartingOta.value = false; isStartingOta.value = false;
otaProgress.value = 100; otaProgress.value = 100;
setTimeout(() => { const completedRunId = otaUpdateRunId;
otaResultTimer = setTimeout(() => {
otaResultTimer = null;
if (completedRunId !== otaUpdateRunId) return;
otaState.value = "update_success"; otaState.value = "update_success";
otaVisible.value = true; otaVisible.value = true;
}, 300); }, 300);
}; };
// 轮询首页直接发起的 OTA 更新任务状态。 // 轮询首页直接发起的 OTA 更新任务状态。
const pollHomeOtaTaskStatus = (taskId) => { const pollHomeOtaTaskStatus = async (taskId, runId) => {
clearTimeout(otaStatusTimer); if (!isOtaUpdateRunActive(runId)) return;
otaStatusTimer = setTimeout(async () => {
otaStatusPollCount += 1;
try { try {
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId); const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
if (!isOtaUpdateRunActive(runId)) return;
const status = Number(taskStatus?.status); const status = Number(taskStatus?.status);
if (status === 2) { if (status === 2) {
completeHomeOtaUpdate(); completeHomeOtaUpdate(runId);
return; return;
} }
if (status === 3) { if (status === 3) {
failHomeOtaUpdate(); failHomeOtaUpdate(runId);
return; return;
} }
if (status === 0 || status === 1) { if (status === 0 || status === 1) {
pollHomeOtaTaskStatus(taskId); if (otaStatusPollCount >= OTA_TASK_STATUS_MAX_POLL_COUNT) {
failHomeOtaUpdate(runId);
return; return;
} }
failHomeOtaUpdate(); otaStatusTimer = setTimeout(() => {
otaStatusTimer = null;
if (!isOtaUpdateRunActive(runId)) return;
void pollHomeOtaTaskStatus(taskId, runId);
}, OTA_TASK_STATUS_POLL_INTERVAL);
return;
}
failHomeOtaUpdate(runId);
} catch (err) { } catch (err) {
failHomeOtaUpdate(); if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(runId);
} }
}, 3000);
}; };
// 设备盒子已连 WiFi 时,从首页直接传空 WiFi 信息发起 OTA 更新。 // 设备盒子已连 WiFi 时,从首页直接传空 WiFi 信息发起 OTA 更新。
const startHomeOtaUpdate = async () => { const startHomeOtaUpdate = async () => {
invalidateOtaUpdateRun();
const runId = otaUpdateRunId;
otaState.value = "update_progress"; otaState.value = "update_progress";
otaVisible.value = true; otaVisible.value = true;
otaProgress.value = 0; otaProgress.value = 0;
startOtaProgressAnimation(); startOtaProgressAnimation();
otaTimeoutTimer = setTimeout(() => { otaTimeoutTimer = setTimeout(() => {
if (otaState.value === "update_progress") { if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(); failHomeOtaUpdate(runId);
} }, OTA_TASK_STATUS_TIMEOUT);
}, 5 * 60 * 1000);
try { try {
const updateResult = await sendHardwareBoxUpdateAPI({ const updateResult = await sendHardwareBoxUpdateAPI({
@@ -188,13 +256,15 @@ const startHomeOtaUpdate = async () => {
wifiPassword: "", wifiPassword: "",
resourceUrl: otaInfo.value.resourceUrl, resourceUrl: otaInfo.value.resourceUrl,
}); });
if (!isOtaUpdateRunActive(runId)) return;
if (!updateResult?.taskId) { if (!updateResult?.taskId) {
failHomeOtaUpdate(); failHomeOtaUpdate(runId);
return; return;
} }
pollHomeOtaTaskStatus(updateResult.taskId); void pollHomeOtaTaskStatus(updateResult.taskId, runId);
} catch (err) { } catch (err) {
failHomeOtaUpdate(); if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(runId);
} }
}; };
@@ -240,6 +310,7 @@ const handleOtaDone = () => {
// 处理 OTA 更新失败后的重试按钮,重新走立即更新判断流程。 // 处理 OTA 更新失败后的重试按钮,重新走立即更新判断流程。
const handleOtaRetry = () => { const handleOtaRetry = () => {
invalidateOtaUpdateRun();
handleOtaUpdate(); handleOtaUpdate();
}; };
@@ -351,7 +422,7 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
clearOtaUpdateTimers(); invalidateOtaUpdateRun();
}); });
onShareAppMessage(() => { onShareAppMessage(() => {
+1 -1
View File
@@ -234,7 +234,7 @@ onLoad(async (options) => {
}); });
onMounted(() => { onMounted(() => {
void audioManager.warmAll(); void audioManager.warmCommon();
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: true, keepScreenOn: true,
}); });
+2
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import Signin from "@/components/Signin.vue"; import Signin from "@/components/Signin.vue";
import SButton from "@/components/SButton.vue"; import SButton from "@/components/SButton.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import { tempBindOrgAPI } from "@/apis"; import { tempBindOrgAPI } from "@/apis";
const scene = ref(""); const scene = ref("");
@@ -129,6 +130,7 @@ onLoad((options) => {
:onClose="closeSignin" :onClose="closeSignin"
:onSuccess="handleLoginSuccess" :onSuccess="handleLoginSuccess"
/> />
<DeviceChargingDialog />
</view> </view>
</template> </template>
+2
View File
@@ -5,6 +5,7 @@ import SButton from "@/components/SButton.vue";
import BowTargetEdit from "@/components/BowTargetEdit.vue"; import BowTargetEdit from "@/components/BowTargetEdit.vue";
import ScreenHint2 from "@/components/ScreenHint2.vue"; import ScreenHint2 from "@/components/ScreenHint2.vue";
import RingBarChart from "@/components/RingBarChart.vue"; import RingBarChart from "@/components/RingBarChart.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import { getPointBookDetailAPI, getPointBookConfigAPI } from "@/apis"; import { getPointBookDetailAPI, getPointBookConfigAPI } from "@/apis";
const selectedIndex = ref(0); const selectedIndex = ref(0);
@@ -238,6 +239,7 @@ const targetTypeName = computed(() => {
</block> </block>
</view> </view>
</ScreenHint2> </ScreenHint2>
<DeviceChargingDialog />
</view> </view>
</template> </template>
+1 -1
View File
@@ -403,7 +403,7 @@ onShow(async () => {
}); });
onMounted(async () => { onMounted(async () => {
void audioManager.warmAll(); void audioManager.warmCommon();
// audioManager.play("第一轮"); // audioManager.play("第一轮");
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: true, keepScreenOn: true,
+1 -1
View File
@@ -418,7 +418,7 @@ onShow(async () => {
}); });
onMounted(async () => { onMounted(async () => {
void audioManager.warmAll(); void audioManager.warmCommon();
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: true, keepScreenOn: true,
}); });
+2
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, ref } from "vue"; import { computed, nextTick, onMounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import Avatar from "@/components/Avatar.vue"; import Avatar from "@/components/Avatar.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import { import {
getMvpRankList, getMvpRankList,
getMyMvpRank, getMyMvpRank,
@@ -524,6 +525,7 @@ const measureTabsMetrics = () => {
{{ getRankUnit() }} {{ getRankUnit() }}
</text> </text>
</view> </view>
<DeviceChargingDialog />
</view> </view>
</template> </template>
+21 -5
View File
@@ -4,18 +4,34 @@ import { getDeviceBatteryAPI } from "@/apis";
const power = ref(0); const power = ref(0);
const timer = ref(null); const timer = ref(null);
let disposed = false;
let requestInFlight = false;
const refreshPower = async () => {
if (disposed || requestInFlight) return;
requestInFlight = true;
try {
const data = await getDeviceBatteryAPI();
if (!disposed) power.value = data.battery;
} catch (_) {
// 电量轮询失败时等待下一轮,避免产生未处理的 Promise 拒绝。
} finally {
requestInFlight = false;
}
};
onMounted(async () => { onMounted(async () => {
const data = await getDeviceBatteryAPI(); await refreshPower();
power.value = data.battery; if (disposed) return;
timer.value = setInterval(async () => { timer.value = setInterval(() => {
const data = await getDeviceBatteryAPI(); void refreshPower();
power.value = data.battery;
}, 1000 * 10); }, 1000 * 10);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true;
clearInterval(timer.value); clearInterval(timer.value);
timer.value = null;
}); });
</script> </script>
@@ -5,6 +5,7 @@ import AppBackground from "./AppBackground.vue";
import Header from "./Header.vue"; import Header from "./Header.vue";
import ScreenHint from "./ScreenHint.vue"; import ScreenHint from "./ScreenHint.vue";
import BackToGame from "./BackToGame.vue"; import BackToGame from "./BackToGame.vue";
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis"; import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
import { capsuleHeight, debounce } from "@/util"; import { capsuleHeight, debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn"; import { returnToBattle } from "@/utils/matchReturn";
@@ -204,6 +205,7 @@ const goCalibration = async () => {
<text>{{ loadingText || "加载中..." }}</text> <text>{{ loadingText || "加载中..." }}</text>
</view> </view>
</view> </view>
<DeviceChargingDialog />
</view> </view>
</template> </template>
+42 -31
View File
@@ -99,8 +99,6 @@ const emit = defineEmits(["shot-effect-complete"]);
const pMode = ref(true); const pMode = ref(true);
const latestOne = ref(null); const latestOne = ref(null);
const bluelatestOne = ref(null); const bluelatestOne = ref(null);
const prevScores = ref([]);
const prevBlueScores = ref([]);
const timer = ref(null); const timer = ref(null);
const dirTimer = ref(null); const dirTimer = ref(null);
const angle = ref(null); const angle = ref(null);
@@ -113,9 +111,23 @@ const targetRect = ref({ left: 0, top: 0, width: 0, height: 0 });
const shakeTimer = ref(null); const shakeTimer = ref(null);
const instance = getCurrentInstance(); const instance = getCurrentInstance();
let shotEffectRequestGeneration = 0; let shotEffectRequestGeneration = 0;
const MAX_VISIBLE_TARGET_SHOTS = 12;
const ROUND_TIP_OFFSET_Y = -32; const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68; const EXPERIENCE_TIP_OFFSET_Y = -68;
// 长时训练只保留最近一组命中点在靶面上,完整成绩仍由父页面保存并用于结算。
const getVisibleScoreEntries = (scores = []) => {
const startIndex = Math.max(scores.length - MAX_VISIBLE_TARGET_SHOTS, 0);
return scores.slice(startIndex).map((shot, offset) => ({
shot,
index: startIndex + offset,
}));
};
const visibleScoreEntries = computed(() => getVisibleScoreEntries(props.scores));
const visibleBlueScoreEntries = computed(() =>
getVisibleScoreEntries(props.blueScores)
);
const getNumber = (value, fallback = 0) => { const getNumber = (value, fallback = 0) => {
const numberValue = Number(value); const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback; return Number.isFinite(numberValue) ? numberValue : fallback;
@@ -376,11 +388,11 @@ function handleWindowResize() {
} }
watch( watch(
() => props.scores, () => props.scores.length,
(newVal) => { (newLength, oldLength = 0) => {
if (newVal.length - prevScores.value.length === 1) { if (newLength - oldLength === 1) {
showShotTip(newVal[newVal.length - 1]); showShotTip(props.scores[newLength - 1]);
} else if (newVal.length < prevScores.value.length) { } else if (newLength < oldLength) {
shotEffectRequestGeneration += 1; shotEffectRequestGeneration += 1;
pendingShotEffect.value = null; pendingShotEffect.value = null;
clearTipTimer(); clearTipTimer();
@@ -388,10 +400,6 @@ watch(
hiddenLatestKey.value = ""; hiddenLatestKey.value = "";
shotEffect.value = null; shotEffect.value = null;
} }
prevScores.value = [...newVal];
},
{
deep: true,
} }
); );
@@ -412,19 +420,15 @@ watch(
); );
watch( watch(
() => props.blueScores, () => props.blueScores.length,
(newVal) => { (newLength, oldLength = 0) => {
if (newVal.length - prevBlueScores.value.length === 1) { if (newLength - oldLength === 1) {
bluelatestOne.value = newVal[newVal.length - 1]; bluelatestOne.value = props.blueScores[newLength - 1];
if (timer.value) clearTimeout(timer.value); if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => { timer.value = setTimeout(() => {
bluelatestOne.value = null; bluelatestOne.value = null;
}, 1000); }, 1000);
} }
prevBlueScores.value = [...newVal];
},
{
deep: true,
} }
); );
@@ -580,38 +584,45 @@ onBeforeUnmount(() => {
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶" >{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}<text v-if="bluelatestOne.ring">环</text></view }}<text v-if="bluelatestOne.ring">环</text></view
> >
<block v-for="(bow, index) in scores" :key="index"> <block v-for="entry in visibleScoreEntries" :key="entry.index">
<image <image
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideLatestHit(index)" v-if="
pMode &&
isSvip &&
entry.shot.ring > 0 &&
!shouldHideLatestHit(entry.index)
"
class="svip-hit-bg" class="svip-hit-bg"
src="../../../static/vip/svip-xuan.png" src="../../../static/vip/svip-xuan.png"
:style="getSvipHitBgStyle(bow)" :style="getSvipHitBgStyle(entry.shot)"
mode="aspectFit" mode="aspectFit"
/> />
<view <view
v-if="bow.ring > 0 && !shouldHideLatestHit(index)" v-if="entry.shot.ring > 0 && !shouldHideLatestHit(entry.index)"
:class="`hit ${pMode ? 'b' : 's'}-point ${ :class="`hit ${pMode ? 'b' : 's'}-point ${
index === scores.length - 1 && latestOne ? 'pump-in' : '' entry.index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`" }`"
:style="{ :style="{
...getHitStyle(bow), ...getHitStyle(entry.shot),
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000', backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
}" }"
><text v-if="pMode">{{ index + 1 }}</text></view ><text v-if="pMode">{{ entry.index + 1 }}</text></view
> >
</block> </block>
<block v-for="(bow, index) in blueScores" :key="index"> <block v-for="entry in visibleBlueScoreEntries" :key="entry.index">
<view <view
v-if="bow.ring > 0" v-if="entry.shot.ring > 0"
:class="`hit ${pMode ? 'b' : 's'}-point ${ :class="`hit ${pMode ? 'b' : 's'}-point ${
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : '' entry.index === blueScores.length - 1 && bluelatestOne
? 'pump-in'
: ''
}`" }`"
:style="{ :style="{
...getHitStyle(bow), ...getHitStyle(entry.shot),
backgroundColor: '#1840FF', backgroundColor: '#1840FF',
}" }"
> >
<text v-if="pMode">{{ index + 1 }}</text> <text v-if="pMode">{{ entry.index + 1 }}</text>
</view> </view>
</block> </block>
<BowShotEffect <BowShotEffect
+36 -17
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue"; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
rowCount: { rowCount: {
type: Number, type: Number,
@@ -27,8 +27,6 @@ const bgImages = [
"../static/complete-light1.png", "../static/complete-light1.png",
"../static/complete-light2.png", "../static/complete-light2.png",
]; ];
const bgIndex = ref(0);
const getDisplayText = (arrow) => { const getDisplayText = (arrow) => {
if (!arrow) return "-"; if (!arrow) return "-";
if (arrow.ringX) return "X"; if (arrow.ringX) return "X";
@@ -47,24 +45,18 @@ watch(
items.value = new Array(newValue).fill(9); items.value = new Array(newValue).fill(9);
} }
); );
const timer = ref(null);
onMounted(() => {
timer.value = setInterval(() => {
bgIndex.value = bgIndex.value === 0 ? 1 : 0;
}, 200);
});
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value);
}
});
</script> </script>
<template> <template>
<view class="container"> <view class="container">
<template v-if="total > 0 && arrows.length === total && completeEffect">
<image <image
v-if="total > 0 && arrows.length === total && completeEffect" v-for="(image, index) in bgImages"
:src="bgImages[bgIndex]" :key="image"
class="complete-light" :src="image"
:class="[
'complete-light',
index === 0 ? 'complete-light--first' : 'complete-light--second',
]"
:style="{ :style="{
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${ width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24)) (100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
@@ -76,6 +68,7 @@ onBeforeUnmount(() => {
top: `${total === 12 ? -2 : -3}vw`, top: `${total === 12 ? -2 : -3}vw`,
}" }"
/> />
</template>
<view <view
v-for="(_, index) in items" v-for="(_, index) in items"
:key="index" :key="index"
@@ -154,4 +147,30 @@ onBeforeUnmount(() => {
.complete-light { .complete-light {
position: absolute; position: absolute;
} }
.complete-light--first {
animation: complete-light-first 400ms steps(1, end) infinite;
}
.complete-light--second {
animation: complete-light-second 400ms steps(1, end) infinite;
}
@keyframes complete-light-first {
0%,
49.9% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes complete-light-second {
0%,
49.9% {
opacity: 0;
}
50%,
100% {
opacity: 1;
}
}
</style> </style>
+16 -1
View File
@@ -99,6 +99,8 @@ const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500; const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600; const AUDIO_TIMEOUT_PER_KEY = 2600;
const AUDIO_TIMEOUT_MAX = 12000; const AUDIO_TIMEOUT_MAX = 12000;
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
let lastRuntimeDiagnosticAt = 0;
const env = computed(() => { const env = computed(() => {
try { try {
@@ -108,6 +110,19 @@ const env = computed(() => {
} }
}); });
const maybeLogRuntimeStats = () => {
if (env.value !== "develop" && env.value !== "trial") return;
const now = Date.now();
if (now - lastRuntimeDiagnosticAt < RUNTIME_DIAGNOSTIC_INTERVAL_MS) return;
lastRuntimeDiagnosticAt = now;
console.log("[training-runtime]", {
scoreCount: scores.value.length,
renderedTargetShotCount: Math.min(scores.value.length, 12),
renderedScoreCardCount: Math.min(scores.value.length, 60) + 1,
...audioManager.getRuntimeStats(),
});
};
const isDistanceStage = computed(() => pageStage.value === pageStages.DISTANCE); const isDistanceStage = computed(() => pageStage.value === pageStages.DISTANCE);
const isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING); const isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING);
const hasPractiseResult = computed(() => !!practiseResult.value?.details); const hasPractiseResult = computed(() => !!practiseResult.value?.details);
@@ -1111,6 +1126,7 @@ async function onReceiveMessage(msg) {
scores.value = msg.details; scores.value = msg.details;
hasNewShot = msg.details.length === previousScoreLength + 1; hasNewShot = msg.details.length === previousScoreLength + 1;
latestShot = hasNewShot ? msg.details[msg.details.length - 1] : null; latestShot = hasNewShot ? msg.details[msg.details.length - 1] : null;
maybeLogRuntimeStats();
} }
if (trainingType.value === "precision") { if (trainingType.value === "precision") {
@@ -1339,7 +1355,6 @@ onBeforeUnmount(() => {
:activeSector="precisionRandomBlock" :activeSector="precisionRandomBlock"
:activeRing="precisionRandomRingArea" :activeRing="precisionRandomRingArea"
:highlightRefreshToken="precisionTargetRefreshToken" :highlightRefreshToken="precisionTargetRefreshToken"
:showSectorLabels="precisionBlocks > 0"
stable-shot-effect stable-shot-effect
@shot-effect-complete="onShotEffectComplete" @shot-effect-complete="onShotEffectComplete"
/> />
+8
View File
@@ -109,6 +109,7 @@ export default defineStore("store", {
tips: "", // 当前提示文案(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题) tips: "", // 当前提示文案(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题)
}, },
dailyCount: getDefaultDailyCount(), dailyCount: getDefaultDailyCount(),
deviceChargingDialogVisible: false,
}), }),
// 计算属性 // 计算属性
@@ -187,6 +188,12 @@ export default defineStore("store", {
...(data || {}), ...(data || {}),
}; };
}, },
showDeviceChargingDialog() {
this.deviceChargingDialogVisible = true;
},
hideDeviceChargingDialog() {
this.deviceChargingDialogVisible = false;
},
clearSessionState() { clearSessionState() {
this.$patch({ this.$patch({
user: getDefaultUser(), user: getDefaultUser(),
@@ -194,6 +201,7 @@ export default defineStore("store", {
online: false, online: false,
game: getDefaultGame(), game: getDefaultGame(),
dailyCount: getDefaultDailyCount(), dailyCount: getDefaultDailyCount(),
deviceChargingDialogVisible: false,
}); });
uni.removeStorageSync(PERSISTED_STORE_KEY); uni.removeStorageSync(PERSISTED_STORE_KEY);
setTimeout(() => { setTimeout(() => {
+15 -6
View File
@@ -8,6 +8,13 @@ let manualClose = false;
let checkingSession = false; let checkingSession = false;
let kickedOut = false; let kickedOut = false;
let isConnecting = false; let isConnecting = false;
const ENABLE_REALTIME_MESSAGE_LOG = (() => {
try {
return uni.getAccountInfoSync().miniProgram.envVersion !== "release";
} catch (_) {
return false;
}
})();
function createWebSocket(token, onMessage) { function createWebSocket(token, onMessage) {
if (!token) return; if (!token) return;
@@ -73,22 +80,22 @@ function createWebSocket(token, onMessage) {
const { data, event } = JSON.parse(res.data); const { data, event } = JSON.parse(res.data);
if (event === "pong") return; if (event === "pong") return;
if (data.type) { if (data.type) {
console.log( if (ENABLE_REALTIME_MESSAGE_LOG) {
"收到 WebSocket 消息", console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
getMessageTypeName(data.type), }
data.data
);
if (onMessage) onMessage({ ...(data.data || {}), type: data.type }); if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return; return;
} }
if (onMessage && data.updates) onMessage(data.updates); if (onMessage && data.updates) onMessage(data.updates);
const msg = data.updates[0]; const msg = data.updates[0];
if (msg) { if (msg) {
if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log( console.log(
"收到 WebSocket 更新", "收到 WebSocket 更新",
getMessageTypeName(msg.constructor), getMessageTypeName(msg.constructor),
msg { updateCount: data.updates.length }
); );
}
if (msg.constructor === MESSAGETYPES.RankUpdate) { if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl); uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) { } else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
@@ -97,6 +104,8 @@ function createWebSocket(token, onMessage) {
uni.$emit("update-online"); uni.$emit("update-online");
} else if (msg.constructor === MESSAGETYPES.DeviceOffline) { } else if (msg.constructor === MESSAGETYPES.DeviceOffline) {
uni.$emit("update-online"); uni.$emit("update-online");
} else if (msg.constructor === MESSAGETYPES.DeviceCharging) {
uni.$emit("device-charging");
} }
} }
}); });
+318 -2051
View File
File diff suppressed because it is too large Load Diff