From 63fb4ae85ad5702685c4dcf743cd739eada35d4d Mon Sep 17 00:00:00 2001
From: zhangyibo95 <690096405@qq.com>
Date: Mon, 17 Aug 2026 10:11:28 +0800
Subject: [PATCH] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?=
=?UTF-8?q?=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/audioManager.js | 143 +++++++++++++++---
src/components/BowPower.vue | 26 +++-
src/components/ScorePanel.vue | 76 ++++++----
src/components/Timer.vue | 7 +-
src/components/TrainingScorePanel.vue | 24 ++-
src/matchWebsocket.js | 29 +++-
src/pages/first-try.vue | 2 +-
src/pages/match-page.vue | 2 +-
src/pages/practise-one.vue | 2 +-
src/pages/practise-two.vue | 2 +-
src/pages/team-battle/components/BowPower.vue | 26 +++-
src/pages/training/components/BowTarget.vue | 73 +++++----
src/pages/training/components/ScorePanel.vue | 77 ++++++----
src/pages/training/practise-one.vue | 16 ++
src/websocket.js | 27 ++--
15 files changed, 395 insertions(+), 137 deletions(-)
diff --git a/src/audioManager.js b/src/audioManager.js
index 35d9de5..6fb4882 100644
--- a/src/audioManager.js
+++ b/src/audioManager.js
@@ -112,6 +112,8 @@ export const audioFils = {
const AUDIO_LOAD_TIMEOUT_MS = 5000;
const AUDIO_WARM_CONCURRENCY = 3;
const AUDIO_WARM_RETRIES = 1;
+const AUDIO_INSTANCE_LIMIT = 24;
+const AUDIO_QUEUE_COMPACT_THRESHOLD = 12;
const AUDIO_WARM_PRIORITY_KEYS = [
"点击按钮",
"设备连接已断开",
@@ -162,6 +164,8 @@ class AudioManager {
this.loadingKeyPromises = new Map();
this.pendingPlayKeys = new Set();
this.cacheWritePromises = new Map();
+ this.audioLastUsedAt = new Map();
+ this.warmPriorityPromise = null;
this.warmAllPromise = null;
// 连续播放队列相关属性
@@ -253,14 +257,74 @@ class AudioManager {
}
getWarmupKeys() {
- return Array.from(
- new Set([...AUDIO_WARM_PRIORITY_KEYS, ...Object.keys(audioFils)])
- );
+ return Array.from(new Set(AUDIO_WARM_PRIORITY_KEYS));
+ }
+
+ 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) {
if (!audioFils[key]) return Promise.resolve(false);
if (this.readyMap.get(key) && this.audioMap.has(key)) {
+ this.touchAudioKey(key);
return Promise.resolve(true);
}
@@ -312,23 +376,39 @@ class AudioManager {
return this.warmKeys(["点击按钮"], { concurrency: 1, retries: 1 });
}
- warmAll() {
- if (this.warmAllPromise) return this.warmAllPromise;
+ warmCommon() {
+ if (this.warmPriorityPromise) return this.warmPriorityPromise;
- debugLog("开始后台分级预热全部语音");
+ debugLog("开始后台预热常用语音");
this.isLoading = true;
- this.warmAllPromise = this.warmKeys(this.getWarmupKeys())
+ this.warmPriorityPromise = this.warmKeys(this.getWarmupKeys())
.catch((error) => {
- debugLog("后台语音预热异常", error);
+ debugLog("常用语音预热异常", error);
})
.finally(() => {
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;
}
- // 保留旧入口兼容测试页或其他历史调用,实际行为改为非阻塞后台预热。
+ // 音频测试页保留全量遍历;实例池会及时淘汰旧实例,避免同时常驻全部语音。
initAudios() {
return this.warmAll();
}
@@ -366,6 +446,7 @@ class AudioManager {
return;
}
+ this.pruneAudioInstances(key);
const audio = uni.createInnerAudioContext();
audio.autoplay = false;
try {
@@ -377,6 +458,7 @@ class AudioManager {
} catch (_) {}
this.allowPlayMap.set(key, false);
audio.onPlay(() => {
+ if (this.audioMap.get(key) !== audio) return;
if (!this.allowPlayMap.get(key)) {
try {
audio.stop();
@@ -414,6 +496,7 @@ class AudioManager {
}
this.readyMap.set(key, false);
this.audioMap.delete(key);
+ this.audioLastUsedAt.delete(key);
try {
audio.destroy();
} catch (_) {}
@@ -446,6 +529,7 @@ class AudioManager {
if (!isCurrentGeneration()) {
clearLoadTimeout();
this.audioMap.delete(key);
+ this.audioLastUsedAt.delete(key);
try {
audio.destroy();
} catch (_) {}
@@ -480,6 +564,7 @@ class AudioManager {
});
audio.onEnded(() => {
+ if (this.audioMap.get(key) !== audio) return;
this.finishPlayback(key, {
advanceSequence: true,
emitEnded: true,
@@ -487,10 +572,12 @@ class AudioManager {
});
audio.onStop(() => {
+ if (this.audioMap.get(key) !== audio) return;
this.finishPlayback(key);
});
this.audioMap.set(key, audio);
+ this.touchAudioKey(key);
audio.src = realSrc;
};
@@ -700,14 +787,7 @@ class AudioManager {
const loadingPromise = this.loadingKeyPromises.get(key);
if (loadingPromise) return loadingPromise;
- this.clearPlayWatchdog(key);
- const oldAudio = this.audioMap.get(key);
- if (oldAudio) {
- try {
- oldAudio.destroy();
- } catch (_) {}
- }
- this.audioMap.delete(key);
+ this.destroyAudioInstance(key);
this.readyMap.set(key, false);
return this.ensureAudio(key);
}
@@ -754,7 +834,8 @@ class AudioManager {
// 不打断当前播放:把新的队列加入到序列中,等待当前播放结束后衔接
if (this.currentPlayingKey) {
if (this.isSequenceRunning) {
- // 已有序列在跑:直接追加
+ // 已有序列在跑:先移除已消费前缀,再追加待播语音。
+ this.compactSequenceQueue(true);
this.sequenceQueue = this.sequenceQueue.concat(queue);
} else {
// 没有序列但当前有正在播放的:以当前为序列的起点
@@ -763,6 +844,17 @@ class AudioManager {
this.sequenceIndex = 0;
// 不触发 _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 {
// 当前没有播放:直接启动新的序列
this.sequenceQueue = queue;
@@ -869,6 +961,7 @@ class AudioManager {
this.currentPlayingKey = key;
this.lastPlayKey = key;
this.lastPlayAt = Date.now();
+ this.touchAudioKey(key);
this.startPlayWatchdog(key);
} else {
debugLog(`音频 ${key} 尚未就绪,按需加载后播放...`);
@@ -885,7 +978,8 @@ class AudioManager {
const nextIndex = this.sequenceIndex + 1;
if (nextIndex < this.sequenceQueue.length) {
this.sequenceIndex = nextIndex;
- const nextKey = this.sequenceQueue[nextIndex];
+ this.compactSequenceQueue();
+ const nextKey = this.sequenceQueue[this.sequenceIndex];
this._playSingle(nextKey, false);
} else {
// 队列播放完成
@@ -969,6 +1063,7 @@ class AudioManager {
reloadAll() {
debugLog("执行 reloadAll: 重置音频实例并后台恢复");
const shouldWarmAll = this.warmAllPromise !== null;
+ const shouldWarmPriority = this.warmPriorityPromise !== null;
this.loadGeneration += 1;
// 1. 停止所有播放
@@ -981,6 +1076,7 @@ class AudioManager {
} catch (_) {}
}
this.audioMap.clear();
+ this.audioLastUsedAt.clear();
// 3. 重置状态
this.readyMap.clear();
@@ -999,10 +1095,13 @@ class AudioManager {
// 4. 重置后台预热状态
this.isLoading = false;
+ this.warmPriorityPromise = null;
this.warmAllPromise = null;
- // 5. 之前已启动过全量预热则继续全量恢复,否则只恢复按钮音效。
- return shouldWarmAll ? this.warmAll() : this.warmButton();
+ // 5. 按中断前使用范围恢复,产品页不再全量创建语音实例。
+ if (shouldWarmAll) return this.warmAll();
+ if (shouldWarmPriority) return this.warmCommon();
+ return this.warmButton();
}
}
diff --git a/src/components/BowPower.vue b/src/components/BowPower.vue
index bfcfecf..528c1e7 100644
--- a/src/components/BowPower.vue
+++ b/src/components/BowPower.vue
@@ -4,18 +4,34 @@ import { getDeviceBatteryAPI } from "@/apis";
const power = ref(0);
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 () => {
- const data = await getDeviceBatteryAPI();
- power.value = data.battery;
- timer.value = setInterval(async () => {
- const data = await getDeviceBatteryAPI();
- power.value = data.battery;
+ await refreshPower();
+ if (disposed) return;
+ timer.value = setInterval(() => {
+ void refreshPower();
}, 1000 * 10);
});
onBeforeUnmount(() => {
+ disposed = true;
clearInterval(timer.value);
+ timer.value = null;
});
diff --git a/src/components/ScorePanel.vue b/src/components/ScorePanel.vue
index 12c57e0..d446669 100644
--- a/src/components/ScorePanel.vue
+++ b/src/components/ScorePanel.vue
@@ -1,5 +1,5 @@
-
+
+
+
{
.complete-light {
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;
+ }
+}
diff --git a/src/components/Timer.vue b/src/components/Timer.vue
index c5f5480..0a73aba 100644
--- a/src/components/Timer.vue
+++ b/src/components/Timer.vue
@@ -9,11 +9,13 @@ const props = defineProps({
const show = ref(false);
const count = ref(props.countdown);
const timer = ref(null);
+const showTimer = ref(null);
const updateTimer = (value) => {
count.value = Math.round(value);
};
onMounted(() => {
- setTimeout(() => {
+ showTimer.value = setTimeout(() => {
+ showTimer.value = null;
show.value = true;
timer.value = setInterval(() => {
if (count.value === 0) {
@@ -27,7 +29,10 @@ onMounted(() => {
uni.$on("update-timer", updateTimer);
});
onBeforeUnmount(() => {
+ if (showTimer.value) clearTimeout(showTimer.value);
+ showTimer.value = null;
if (timer.value) clearInterval(timer.value);
+ timer.value = null;
uni.$off("update-timer", updateTimer);
});
diff --git a/src/components/TrainingScorePanel.vue b/src/components/TrainingScorePanel.vue
index 30636f6..f374281 100644
--- a/src/components/TrainingScorePanel.vue
+++ b/src/components/TrainingScorePanel.vue
@@ -1,6 +1,8 @@
diff --git a/src/pages/training/components/BowTarget.vue b/src/pages/training/components/BowTarget.vue
index 719a150..4a140c1 100644
--- a/src/pages/training/components/BowTarget.vue
+++ b/src/pages/training/components/BowTarget.vue
@@ -99,8 +99,6 @@ const emit = defineEmits(["shot-effect-complete"]);
const pMode = ref(true);
const latestOne = ref(null);
const bluelatestOne = ref(null);
-const prevScores = ref([]);
-const prevBlueScores = ref([]);
const timer = ref(null);
const dirTimer = 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 instance = getCurrentInstance();
let shotEffectRequestGeneration = 0;
+const MAX_VISIBLE_TARGET_SHOTS = 12;
const ROUND_TIP_OFFSET_Y = -32;
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 numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
@@ -376,11 +388,11 @@ function handleWindowResize() {
}
watch(
- () => props.scores,
- (newVal) => {
- if (newVal.length - prevScores.value.length === 1) {
- showShotTip(newVal[newVal.length - 1]);
- } else if (newVal.length < prevScores.value.length) {
+ () => props.scores.length,
+ (newLength, oldLength = 0) => {
+ if (newLength - oldLength === 1) {
+ showShotTip(props.scores[newLength - 1]);
+ } else if (newLength < oldLength) {
shotEffectRequestGeneration += 1;
pendingShotEffect.value = null;
clearTipTimer();
@@ -388,10 +400,6 @@ watch(
hiddenLatestKey.value = "";
shotEffect.value = null;
}
- prevScores.value = [...newVal];
- },
- {
- deep: true,
}
);
@@ -412,19 +420,15 @@ watch(
);
watch(
- () => props.blueScores,
- (newVal) => {
- if (newVal.length - prevBlueScores.value.length === 1) {
- bluelatestOne.value = newVal[newVal.length - 1];
+ () => props.blueScores.length,
+ (newLength, oldLength = 0) => {
+ if (newLength - oldLength === 1) {
+ bluelatestOne.value = props.blueScores[newLength - 1];
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
bluelatestOne.value = null;
}, 1000);
}
- prevBlueScores.value = [...newVal];
- },
- {
- deep: true,
}
);
@@ -580,38 +584,45 @@ onBeforeUnmount(() => {
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}环
-
+
{{ index + 1 }}{{ entry.index + 1 }}
-
+
- {{ index + 1 }}
+ {{ entry.index + 1 }}
-import { ref, watch, onMounted, onBeforeUnmount } from "vue";
+import { ref, watch } from "vue";
const props = defineProps({
rowCount: {
type: Number,
@@ -27,8 +27,6 @@ const bgImages = [
"../static/complete-light1.png",
"../static/complete-light2.png",
];
-const bgIndex = ref(0);
-
const getDisplayText = (arrow) => {
if (!arrow) return "-";
if (arrow.ringX) return "X";
@@ -47,35 +45,30 @@ watch(
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);
- }
-});
-
+
+
+
{
.complete-light {
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;
+ }
+}
diff --git a/src/pages/training/practise-one.vue b/src/pages/training/practise-one.vue
index e8e1325..edf7ef3 100644
--- a/src/pages/training/practise-one.vue
+++ b/src/pages/training/practise-one.vue
@@ -99,6 +99,8 @@ const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600;
const AUDIO_TIMEOUT_MAX = 12000;
+const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
+let lastRuntimeDiagnosticAt = 0;
const env = computed(() => {
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 isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING);
const hasPractiseResult = computed(() => !!practiseResult.value?.details);
@@ -1111,6 +1126,7 @@ async function onReceiveMessage(msg) {
scores.value = msg.details;
hasNewShot = msg.details.length === previousScoreLength + 1;
latestShot = hasNewShot ? msg.details[msg.details.length - 1] : null;
+ maybeLogRuntimeStats();
}
if (trainingType.value === "precision") {
diff --git a/src/websocket.js b/src/websocket.js
index b80ce81..9a1902a 100644
--- a/src/websocket.js
+++ b/src/websocket.js
@@ -8,6 +8,13 @@ let manualClose = false;
let checkingSession = false;
let kickedOut = false;
let isConnecting = false;
+const ENABLE_REALTIME_MESSAGE_LOG = (() => {
+ try {
+ return uni.getAccountInfoSync().miniProgram.envVersion !== "release";
+ } catch (_) {
+ return false;
+ }
+})();
function createWebSocket(token, onMessage) {
if (!token) return;
@@ -73,22 +80,22 @@ function createWebSocket(token, onMessage) {
const { data, event } = JSON.parse(res.data);
if (event === "pong") return;
if (data.type) {
- console.log(
- "收到 WebSocket 消息",
- getMessageTypeName(data.type),
- data.data
- );
+ if (ENABLE_REALTIME_MESSAGE_LOG) {
+ console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
+ }
if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return;
}
if (onMessage && data.updates) onMessage(data.updates);
const msg = data.updates[0];
if (msg) {
- console.log(
- "收到 WebSocket 更新",
- getMessageTypeName(msg.constructor),
- msg
- );
+ if (ENABLE_REALTIME_MESSAGE_LOG) {
+ console.log(
+ "收到 WebSocket 更新",
+ getMessageTypeName(msg.constructor),
+ { updateCount: data.updates.length }
+ );
+ }
if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {