diff --git a/.gitignore b/.gitignore index c91affc..23a9575 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules .history .github +.claude openspec CLAUDE.md docs diff --git a/src/App.vue b/src/App.vue index 08b356c..b62dd14 100644 --- a/src/App.vue +++ b/src/App.vue @@ -258,6 +258,69 @@ text-overflow: ellipsis; } + .member-nickname { + position: relative; + display: inline-flex; + max-width: 100%; + overflow: hidden; + } + + .member-nickname__text, + .member-nickname__shine { + display: block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .member-nickname--vip .member-nickname__text { + color: #E7BA80; + } + + .member-nickname--svip .member-nickname__text { + background: linear-gradient(90deg, #ffb86c, #ff4fd8, #7c5cff, #35d6ff); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + + .member-nickname__shine { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 110deg, + transparent 0%, + transparent 38%, + rgba(255, 255, 255, 0.15) 45%, + rgba(255, 255, 255, 1) 50%, + rgba(255, 255, 255, 0.15) 55%, + transparent 62%, + transparent 100% + ); + background-size: 220% 100%; + background-position: 120% 0; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + pointer-events: none; + animation: memberNicknameShine 3.5s infinite ease-in-out; + } + + @keyframes memberNicknameShine { + 0%, + 50% { + background-position: 120% 0; + } + + 100% { + background-position: -200% 0; + } + } + .modal { height: 100%; display: flex; diff --git a/src/apis.js b/src/apis.js index a816bf7..de645e1 100644 --- a/src/apis.js +++ b/src/apis.js @@ -23,7 +23,10 @@ try { console.error("获取环境信息失败,使用默认正式环境", e); } -function request(method, url, data = {}) { +const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot"); + +// 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。 +function request(method, url, data = {}, baseUrl = BASE_URL) { const token = uni.getStorageSync( `${uni.getAccountInfoSync().miniProgram.envVersion}_token` ); @@ -31,16 +34,21 @@ function request(method, url, data = {}) { if (token) header.Authorization = `Bearer ${token || ""}`; return new Promise((resolve, reject) => { uni.request({ - url: `${BASE_URL}${url}`, + url: `${baseUrl}${url}`, method, header, data, timeout: 10000, success: (res) => { + if (url === "/user/hardwareBox/connectWifi" && res.statusCode === 200 && res.data && Object.keys(res.data).length === 0) { + resolve({}); + return; + } if (res.data) { const {code, data, message} = res.data; if (code === 0) resolve(data); else if (message) { + const error = {code, data, message}; if (message.indexOf("登录身份已失效") !== -1) { console.log('1111111111111111111,token失效') uni.removeStorageSync( @@ -50,6 +58,10 @@ function request(method, url, data = {}) { reject({ type: "AUTH_INVALID", message }); return; } + if (message.indexOf("已达上限") !== -1) { + reject(error); + return; + } if (message === "ROOM_FULL") { resolve({full: true}); return; @@ -97,8 +109,10 @@ function request(method, url, data = {}) { title: message, icon: "none", }); + reject(error); + return; } - reject(""); + reject({code, data, message}); } }, fail: (err) => { @@ -167,6 +181,10 @@ export const getAppConfig = () => { return request("GET", "/index/appConfig"); }; +export const getDailyCountAPI = () => { + return request("GET", "/index/dailyCount", {}, ADDONS_BASE_URL); +}; + export const getHomeData = (seasonId) => { return request("GET", `/user/myHome?seasonId=${seasonId}`); }; @@ -342,9 +360,23 @@ export const createOrderAPI = (vipId) => { quanity: 1, tradeType: "mini", payType: "wxpay", + returnUrl: "", + remark: "", + mockTest: false, }); }; +export const virtualPayOrderAPI = (vipId = 0, code = "") => { + return request("POST", "/user/virtualPay/createOrder", { + vipId, + code, + }); +}; + +export const getOrderDetailAPI = (orderId) => { + return request("GET", `/user/order/detail?orderId=${encodeURIComponent(orderId)}`); +}; + export const payOrderAPI = (id) => { return request("POST", "/user/order/pay", { id, @@ -457,6 +489,26 @@ export const getDeviceBatteryAPI = async () => { return request("GET", "/user/device/battery"); }; +// 设备连接指定 WiFi,只下发 WiFi 凭证,不触发 OTA 升级。 +export const connectDeviceWifiAPI = async (ssid, password) => { + return request("POST", "/user/hardwareBox/connectWifi", {ssid, password}); +}; + +// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。 +export const getHardwareBoxVersionAPI = async () => { + return request("GET", "/user/hardwareBox/version"); +}; + +// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。 +export const sendHardwareBoxUpdateAPI = async (data) => { + return request("POST", "/user/hardwareBox/sendUpdate", data); +}; + +// 根据任务 ID 获取硬件盒子 OTA 更新状态。 +export const getHardwareBoxTaskStatusAPI = async (taskId) => { + return request("GET", `/user/hardwareBox/taskStatus?taskId=${taskId}`); +}; + export const addNoteAPI = async (id, remark) => { return request("POST", "/user/score/sheet/remark", {id, remark}); }; diff --git a/src/audioManager.js b/src/audioManager.js index a0f55bb..156553c 100644 --- a/src/audioManager.js +++ b/src/audioManager.js @@ -40,6 +40,8 @@ export const audioFils = { "https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya59b6pu0ur4um.mp3", 比赛开始: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuu5z3a3lumkutske.mp3", + 下半场开始: + "https://static.shelingxingqiu.com/shootmini/static/audio/%E4%B8%8B%E5%8D%8A%E5%9C%BA%E5%BC%80%E5%A7%8B.mp3", 请开始射击: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3", 射击无效: @@ -131,6 +133,10 @@ class AudioManager { this.lastPlayKey = null; this.lastPlayAt = 0; this.isInterrupted = false; + this.interruptedAt = 0; + this.interruptionFallbackMs = 5000; + this.playWatchdogMs = 8000; + this.playWatchdogTimers = new Map(); // 静音开关 this.isMuted = false; @@ -157,6 +163,7 @@ class AudioManager { const begin = () => { if (this.isInterrupted) return; this.isInterrupted = true; + this.interruptedAt = Date.now(); this.stopAll(); this.isSequenceRunning = false; this.sequenceQueue = []; @@ -168,6 +175,7 @@ class AudioManager { const end = () => { if (!this.isInterrupted) return; this.isInterrupted = false; + this.interruptedAt = 0; uni.$emit(AUDIO_INTERRUPTION_END_EVENT); void this.reloadAll(); }; @@ -350,9 +358,14 @@ class AudioManager { const loadTimeout = setTimeout(() => { debugLog(`音频 ${key} 加载超时`); this.recordLoadFailure(key); + this.audioMap.delete(key); try { audio.destroy(); } catch (_) {} + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + }); if (callback) callback(); }, 10000); @@ -386,7 +399,13 @@ class AudioManager { } this.recordLoadFailure(key); this.audioMap.delete(key); - audio.destroy(); + try { + audio.destroy(); + } catch (_) {} + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + }); if (this.readyMap.get(key)) { // 这里不要去除,不然检查进度的时候由于没有重新加载而进度卡住,等播放失败的时候会重新加载 // this.readyMap.set(key, false); @@ -396,19 +415,14 @@ class AudioManager { }); audio.onEnded(() => { - if (this.currentPlayingKey === key) { - this.currentPlayingKey = null; - } - this.allowPlayMap.set(key, false); - this.onAudioEnded(key); - uni.$emit('audioEnded', key); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + }); }); audio.onStop(() => { - if (this.currentPlayingKey === key) { - this.currentPlayingKey = null; - } - this.allowPlayMap.set(key, false); + this.finishPlayback(key); }); this.audioMap.set(key, audio); @@ -446,11 +460,19 @@ class AudioManager { }); } else { this.recordLoadFailure(key); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + }); if (callback) callback(); } }, fail: () => { this.recordLoadFailure(key); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + }); if (callback) callback(); }, }); @@ -487,15 +509,137 @@ class AudioManager { this.failedLoadKeys.add(key); } + clearPlayWatchdog(key) { + const timer = this.playWatchdogTimers.get(key); + if (timer) { + clearTimeout(timer); + this.playWatchdogTimers.delete(key); + } + } + + clearAllPlayWatchdogs() { + for (const timer of this.playWatchdogTimers.values()) { + clearTimeout(timer); + } + this.playWatchdogTimers.clear(); + } + + startPlayWatchdog(key) { + this.clearPlayWatchdog(key); + const timer = setTimeout(() => { + if (this.currentPlayingKey !== key) return; + debugLog(`音频 ${key} 播放超时,跳过当前音频并继续队列`); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + force: true, + }); + this.reloadAudioKey(key); + }, this.playWatchdogMs); + this.playWatchdogTimers.set(key, timer); + } + + finishPlayback(key, { advanceSequence = false, emitEnded = false, force = false } = {}) { + const wasCurrent = this.currentPlayingKey === key; + const isSequenceCurrent = + this.isSequenceRunning && this.sequenceQueue[this.sequenceIndex] === key; + + this.clearPlayWatchdog(key); + this.allowPlayMap.set(key, false); + + if (!force && !wasCurrent && !isSequenceCurrent) return false; + + if (wasCurrent) { + this.currentPlayingKey = null; + } + + if (advanceSequence && isSequenceCurrent) { + this.onAudioEnded(key); + } + + if (emitEnded) { + uni.$emit("audioEnded", key); + } + + return true; + } + + recoverFromInterruptionIfStale(force = false) { + if (!this.isInterrupted) return false; + const interruptedFor = Date.now() - (this.interruptedAt || Date.now()); + if (!force && interruptedFor < this.interruptionFallbackMs) return false; + + debugLog("音频中断状态超时,执行兜底恢复"); + this.isInterrupted = false; + this.interruptedAt = 0; + uni.$emit(AUDIO_INTERRUPTION_END_EVENT); + void this.reloadAll(); + return true; + } + + recoverIfStale(expectedKey) { + if (this.recoverFromInterruptionIfStale(true)) return; + + const key = + expectedKey || this.currentPlayingKey || this.sequenceQueue[this.sequenceIndex]; + if (!key) { + if (this.isSequenceRunning) { + this.sequenceQueue = []; + this.sequenceIndex = 0; + this.isSequenceRunning = false; + } + return; + } + + const isStaleCurrent = + this.currentPlayingKey === key || + (this.isSequenceRunning && this.sequenceQueue[this.sequenceIndex] === key); + if (!isStaleCurrent) return; + + debugLog(`音频 ${key} 等待超时,执行轻量恢复`); + const audio = this.audioMap.get(key); + if (audio) { + try { + audio.stop(); + } catch (_) {} + } + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + force: true, + }); + this.reloadAudioKey(key); + } + + reloadAudioKey(key) { + const audio = this.audioMap.get(key); + if (audio) { + try { + audio.destroy(); + } catch (_) {} + this.audioMap.delete(key); + } + this.readyMap.set(key, false); + this.retryLoadAudio(key); + } + // 重新加载音频 retryLoadAudio(key) { + this.clearPlayWatchdog(key); const oldAudio = this.audioMap.get(key); - if (oldAudio) oldAudio.destroy(); + if (oldAudio) { + try { + oldAudio.destroy(); + } catch (_) {} + } this.createAudio(key); } // 播放指定音频或音频数组(数组则按顺序连续播放) play(input, interrupt = true) { + if (this.isInterrupted) { + this.recoverFromInterruptionIfStale(); + } if (this.isInterrupted) { debugLog("音频处理中断状态,忽略播放请求"); return; @@ -553,6 +697,9 @@ class AudioManager { // 内部方法:播放单个 key _playSingle(key, forceStopAll = false) { + if (this.isInterrupted) { + this.recoverFromInterruptionIfStale(); + } if (this.isInterrupted) { debugLog(`音频处理中断状态,跳过播放: ${key}`); return; @@ -561,6 +708,11 @@ class AudioManager { const now = Date.now(); if (this.lastPlayKey === key && now - this.lastPlayAt < 250) { debugLog(`忽略快速重复播放: ${key}`); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + force: true, + }); return; } @@ -603,21 +755,34 @@ class AudioManager { try { audio.play(); } catch (err) { - this.allowPlayMap.set(key, false); + this.finishPlayback(key, { + advanceSequence: true, + emitEnded: true, + force: true, + }); debugLog(`音频 ${key} 播放调用失败`, err?.errMsg || err); return; } this.currentPlayingKey = key; this.lastPlayKey = key; this.lastPlayAt = Date.now(); + this.startPlayWatchdog(key); } else { debugLog(`音频 ${key} 不存在,尝试重新加载...`); this.retryLoadAudio(key); + let loadWaitTimer = null; + const cleanup = () => { + try { + uni.$off("audioLoaded", handler); + } catch (_) {} + if (loadWaitTimer) { + clearTimeout(loadWaitTimer); + loadWaitTimer = null; + } + }; const handler = (loadedKey) => { if (loadedKey === key) { - try { - uni.$off("audioLoaded", handler); - } catch (_) {} + cleanup(); // 再次校验是否存在且就绪 const a = this.audioMap.get(key); if (a && this.readyMap.get(key)) { @@ -628,6 +793,7 @@ class AudioManager { try { uni.$on("audioLoaded", handler); } catch (_) {} + loadWaitTimer = setTimeout(cleanup, 12000); } } @@ -653,6 +819,7 @@ class AudioManager { // 停止指定音频 stop(key) { const audio = this.audioMap.get(key); + this.clearPlayWatchdog(key); if (audio) { audio.stop(); this.allowPlayMap.set(key, false); @@ -664,6 +831,7 @@ class AudioManager { // 停止所有音频 stopAll() { + this.clearAllPlayWatchdogs(); for (const [k, audio] of this.audioMap.entries()) { try { audio.stop(); @@ -737,6 +905,7 @@ class AudioManager { this.readyMap.clear(); this.failedLoadKeys.clear(); this.allowPlayMap.clear(); + this.clearAllPlayWatchdogs(); this.currentPlayingKey = null; this.sequenceQueue = []; this.sequenceIndex = 0; diff --git a/src/canvas.js b/src/canvas.js index e5e41f4..7c5b66b 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -456,23 +456,29 @@ export const generateShareImage = async (canvasId, data) => { // 2D 即时绘制,无需 ctx.draw() } catch (e) { console.error("generateShareImage 绘制失败:", e); + throw e; } }; // 顶部导入与工具方法 async function getCanvas2DContext(canvasId, targetWidth, targetHeight) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const query = uni.createSelectorQuery(); query .select(`#${canvasId}`) .fields({ node: true, size: true }) .exec((res) => { - const { node: canvas } = res[0] || {}; + const canvasInfo = res && res[0]; + const { node: canvas } = canvasInfo || {}; + if (!canvas || typeof canvas.getContext !== "function") { + reject(new Error(`canvas ${canvasId} not found`)); + return; + } const ctx = canvas.getContext("2d"); const dpr = uni.getSystemInfoSync().pixelRatio || 1; - const w = targetWidth || res[0].width; - const h = targetHeight || res[0].height; + const w = targetWidth || canvasInfo.width; + const h = targetHeight || canvasInfo.height; canvas.width = w * dpr; canvas.height = h * dpr; @@ -561,6 +567,7 @@ export const sharePointData = async (canvasId, data) => { // 2D 即时绘制,无需 ctx.draw() } catch (e) { console.error("generateShareImage 绘制失败:", e); + throw e; } }; @@ -778,6 +785,7 @@ export async function sharePractiseData(canvasId, type, user, data) { // 2D 模式下无需 ctx.draw() } catch (err) { console.log(err); + throw err; } } diff --git a/src/components/AppBackground.vue b/src/components/AppBackground.vue index 134b66e..00feb51 100644 --- a/src/components/AppBackground.vue +++ b/src/components/AppBackground.vue @@ -57,6 +57,12 @@ const props = defineProps({ src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png" mode="widthFix" /> + diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue index 05f2ebe..bfe2831 100644 --- a/src/components/AppFooter.vue +++ b/src/components/AppFooter.vue @@ -8,7 +8,7 @@ const tabs = [ function handleTabClick(index) { if (index === 0) { uni.navigateTo({ - url: "/pages/be-vip", + url: "/pages/member/be-vip", }); } if (index === 1) { diff --git a/src/components/BackToGame.vue b/src/components/BackToGame.vue index b5a509c..5297d74 100644 --- a/src/components/BackToGame.vue +++ b/src/components/BackToGame.vue @@ -18,12 +18,14 @@ const props = defineProps({ }, }); const loading = ref(false); +const navigating = ref(false); /** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */ const getToken = () => uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`); onShow(async () => { + navigating.value = false; if (user.value.id && getToken()) { setTimeout(async () => { const state = await getUserGameState(); @@ -45,28 +47,35 @@ watch( } ); +const navigateOnce = (url) => + new Promise((resolve, reject) => { + navigating.value = true; + uni.navigateTo({ + url, + success: resolve, + fail: (error) => { + navigating.value = false; + reject(error); + }, + }); + }); + const onClick = debounce(async () => { - if (loading.value) return; + if (loading.value || navigating.value) return; try { loading.value = true; const result = await getBattleAPI(); if (result && result.matchId) { await uni.$checkAudio(); if (result.mode <= 3) { - uni.navigateTo({ - url: `/pages/team-battle/index?battleId=${result.matchId}`, - }); + await navigateOnce(`/pages/team-battle/index?battleId=${result.matchId}`); } else { - uni.navigateTo({ - url: `/pages/melee-battle?battleId=${result.matchId}`, - }); + await navigateOnce(`/pages/melee-battle?battleId=${result.matchId}`); } return; } if (game.value.roomID) { - uni.navigateTo({ - url: "/pages/battle-room?roomNumber=" + game.value.roomID, - }); + await navigateOnce("/pages/battle-room?roomNumber=" + game.value.roomID); } else { updateGame(false, ""); } diff --git a/src/components/BattleHeader.vue b/src/components/BattleHeader.vue index b12d84f..b5cafcf 100644 --- a/src/components/BattleHeader.vue +++ b/src/components/BattleHeader.vue @@ -27,6 +27,14 @@ defineProps({ default: true, }, }); + +const getMemberNicknameClass = (player = {}) => [ + "member-nickname", + player.vip === true && player.sVip !== true ? "member-nickname--vip" : "", + player.sVip === true ? "member-nickname--svip" : "", +]; + +const isMember = (player = {}) => player.vip === true || player.sVip === true;