Compare commits
63 Commits
new-race-m
...
ota-mode
| Author | SHA1 | Date | |
|---|---|---|---|
| 54e5427a77 | |||
| 9b47b313a7 | |||
| 350c6373ab | |||
| 048b0e9fa9 | |||
| 39d34d5bff | |||
| 37f4f0c019 | |||
| e9cd9c8c9f | |||
| 73a41ab897 | |||
| 32c37e283f | |||
| 209103742d | |||
| 27d11a8129 | |||
| c9ea368a19 | |||
| b5e7e72104 | |||
| 7a00c1bb1f | |||
| 8ef64f8f42 | |||
| 8664ae9fe4 | |||
| 8830650560 | |||
| 52b26fdcfb | |||
| 60a0740c31 | |||
| c4a3512f30 | |||
| 5228519c4f | |||
| e830a63deb | |||
| a885ce0dce | |||
| de7b44aa34 | |||
| 7cbdfdceab | |||
| 20113fc4bf | |||
| 1ade861aae | |||
| 3293356668 | |||
| fefdb3527d | |||
| 076769c4d8 | |||
| 7d503c09e0 | |||
| 15a3e4120d | |||
| 192db06ac5 | |||
| 162a6820e9 | |||
| 852b2ce799 | |||
| 2b14274453 | |||
| 33d28c6a45 | |||
| 301fc4ee42 | |||
| 6596ea977d | |||
| f272df912b | |||
| cbb7bce746 | |||
| a5c6afe647 | |||
| 0b93d99688 | |||
| 3bc2e6c14b | |||
| ff64fab323 | |||
| 43123df18f | |||
| 860b01d5eb | |||
| 9c554a9366 | |||
| 8d918b008e | |||
| a66d8a03c6 | |||
| b168fd54a4 | |||
| da023c60f5 | |||
| cb4027418d | |||
| 96cc69f041 | |||
| 860cdbe332 | |||
| 8b9c862b96 | |||
| 19391808ef | |||
| 9cc6ef4b88 | |||
| fc14489ab3 | |||
| aba2da56d7 | |||
| 24314e5ec8 | |||
| 4dcfdeda68 | |||
| a6becf67ff |
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
.history
|
.history
|
||||||
.github
|
.github
|
||||||
|
.claude
|
||||||
openspec
|
openspec
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs
|
docs
|
||||||
|
|||||||
18
src/App.vue
@@ -21,7 +21,8 @@
|
|||||||
} = storeToRefs(store);
|
} = storeToRefs(store);
|
||||||
const {
|
const {
|
||||||
updateUser,
|
updateUser,
|
||||||
updateOnline
|
updateOnline,
|
||||||
|
clearSessionState
|
||||||
} = store;
|
} = store;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -46,6 +47,17 @@
|
|||||||
updateUser(value);
|
updateUser(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSessionKickedOut() {
|
||||||
|
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
|
uni.removeStorageSync(`${env}_token`);
|
||||||
|
clearSessionState();
|
||||||
|
uni.showModal({
|
||||||
|
title: "提示",
|
||||||
|
content: "账号已在其他设备登录",
|
||||||
|
showCancel: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function emitUpdateOnline() {
|
async function emitUpdateOnline() {
|
||||||
const data = await getDeviceBatteryAPI();
|
const data = await getDeviceBatteryAPI();
|
||||||
updateOnline(data.online);
|
updateOnline(data.online);
|
||||||
@@ -65,6 +77,7 @@
|
|||||||
onShow(() => {
|
onShow(() => {
|
||||||
uni.$on("update-user", emitUpdateUser);
|
uni.$on("update-user", emitUpdateUser);
|
||||||
uni.$on("update-online", emitUpdateOnline);
|
uni.$on("update-online", emitUpdateOnline);
|
||||||
|
uni.$on("session-kicked-out", onSessionKickedOut);
|
||||||
const token = uni.getStorageSync(
|
const token = uni.getStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
@@ -77,6 +90,7 @@
|
|||||||
onHide(() => {
|
onHide(() => {
|
||||||
uni.$off("update-user", emitUpdateUser);
|
uni.$off("update-user", emitUpdateUser);
|
||||||
uni.$off("update-online", emitUpdateOnline);
|
uni.$off("update-online", emitUpdateOnline);
|
||||||
|
uni.$off("session-kicked-out", onSessionKickedOut);
|
||||||
websocket.closeWebSocket();
|
websocket.closeWebSocket();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -289,4 +303,4 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ function request(method, url, data = {}) {
|
|||||||
if (code === 0) resolve(data);
|
if (code === 0) resolve(data);
|
||||||
else if (message) {
|
else if (message) {
|
||||||
if (message.indexOf("登录身份已失效") !== -1) {
|
if (message.indexOf("登录身份已失效") !== -1) {
|
||||||
|
console.log('1111111111111111111,token失效')
|
||||||
uni.removeStorageSync(
|
uni.removeStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
uni.$emit("update-user");
|
uni.$emit("update-user");
|
||||||
|
reject({ type: "AUTH_INVALID", message });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (message === "ROOM_FULL") {
|
if (message === "ROOM_FULL") {
|
||||||
resolve({full: true});
|
resolve({full: true});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
audio.play();
|
try {
|
||||||
|
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();
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const onClick = debounce(async () => {
|
|||||||
await uni.$checkAudio();
|
await uni.$checkAudio();
|
||||||
if (result.mode <= 3) {
|
if (result.mode <= 3) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/team-battle?battleId=${result.matchId}`,
|
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const backToGame = debounce(async () => {
|
|||||||
await checkAudioProgress();
|
await checkAudioProgress();
|
||||||
if (result.mode <= 3) {
|
if (result.mode <= 3) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/team-battle?battleId=${result.matchId}`,
|
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
|
|||||||
@@ -185,7 +185,13 @@ onBeforeUnmount(() => {
|
|||||||
}}</text
|
}}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="currentPage === 'pages/team-battle'" class="battle-progress">
|
<view
|
||||||
|
v-if="
|
||||||
|
currentPage === 'pages/team-battle' ||
|
||||||
|
currentPage === 'pages/team-battle/index'
|
||||||
|
"
|
||||||
|
class="battle-progress"
|
||||||
|
>
|
||||||
<HeaderProgress />
|
<HeaderProgress />
|
||||||
</view>
|
</view>
|
||||||
<!-- 对战房间:整个胶囊为分享按钮,房号从 Store 读取;fixed 定位紧靠系统胶囊左侧 -->
|
<!-- 对战房间:整个胶囊为分享按钮,房号从 Store 读取;fixed 定位紧靠系统胶囊左侧 -->
|
||||||
|
|||||||
396
src/components/OtaModal.vue
Normal 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;
|
||||||
|
}
|
||||||
|
/* 设计图:吉祥物向上突出弹窗顶部 40px(375px基准)× 2 = 80rpx */
|
||||||
|
.outer-new {
|
||||||
|
padding-top: 80rpx;
|
||||||
|
}
|
||||||
|
.outer-result {
|
||||||
|
padding-top: 80rpx;
|
||||||
|
padding-bottom: 66rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 浮动图标(绝对定位,位于卡片顶部上方) */
|
||||||
|
.float-icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
/* 吉祥物尺寸:设计图 149×109px(375px基准)× 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, computed } from "vue";
|
import { ref, watch } from "vue";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isRed: {
|
isRed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
currentShooterId: {
|
currentShooterId: {
|
||||||
type: Number,
|
type: [Number, String],
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -30,31 +30,35 @@ const getPos = (id) => {
|
|||||||
return sort * 40;
|
return sort * 40;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const syncPlayers = () => {
|
||||||
props.team.forEach((p, index) => {
|
const nextPlayers = {};
|
||||||
players.value[p.id] = { sort: index, ...p };
|
const shooterId = props.currentShooterId;
|
||||||
|
const shooterIndex = props.team.findIndex(
|
||||||
|
(p) => String(p?.id) === String(shooterId)
|
||||||
|
);
|
||||||
|
const nextTeam = [...props.team];
|
||||||
|
|
||||||
|
currentTeam.value = !!shooterId && shooterIndex >= 0;
|
||||||
|
firstName.value = "";
|
||||||
|
|
||||||
|
if (currentTeam.value) {
|
||||||
|
const target = nextTeam.splice(shooterIndex, 1)[0];
|
||||||
|
if (target) {
|
||||||
|
nextTeam.unshift(target);
|
||||||
|
firstName.value = target.name || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTeam.forEach((p, index) => {
|
||||||
|
if (p?.id) nextPlayers[p.id] = { sort: index, ...p };
|
||||||
});
|
});
|
||||||
});
|
players.value = nextPlayers;
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.currentShooterId,
|
[() => props.team, () => props.currentShooterId],
|
||||||
(newVal) => {
|
syncPlayers,
|
||||||
if (!newVal) return;
|
{ immediate: true, deep: true }
|
||||||
const index = props.team.findIndex((p) => p.id === newVal);
|
|
||||||
currentTeam.value = index >= 0;
|
|
||||||
if (index >= 0) {
|
|
||||||
const newPlayers = [...props.team];
|
|
||||||
const target = newPlayers.splice(index, 1)[0];
|
|
||||||
if (target) {
|
|
||||||
newPlayers.unshift(target);
|
|
||||||
firstName.value = target.name;
|
|
||||||
newPlayers.forEach((p, index) => {
|
|
||||||
players.value[p.id] = { sort: index, ...p };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<view
|
<view
|
||||||
v-for="(item, index) in team"
|
v-for="(item, index) in team"
|
||||||
:key="index"
|
:key="item.id || index"
|
||||||
class="player"
|
class="player"
|
||||||
:style="{
|
:style="{
|
||||||
width: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
width: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||||
|
|||||||
@@ -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" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/team-battle"
|
"path": "pages/team-battle/index"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/melee-battle"
|
"path": "pages/melee-battle"
|
||||||
@@ -119,6 +119,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/mine-bow-data"
|
"path": "pages/mine-bow-data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ota-wifi",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ async function onReceiveMessage(message) {
|
|||||||
uni.setStorageSync("blue-team", message.teams[1].players || []);
|
uni.setStorageSync("blue-team", message.teams[1].players || []);
|
||||||
uni.setStorageSync("red-team", message.teams[2].players || []);
|
uni.setStorageSync("red-team", message.teams[2].players || []);
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: "/pages/team-battle?battleId=" + message.matchId,
|
url: "/pages/team-battle/index?battleId=" + message.matchId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
|
|||||||
@@ -278,9 +278,7 @@ async function exit() {
|
|||||||
url: `/pages/battle-room?roomNumber=${data.value.roomId}&fromResult=1`,
|
url: `/pages/battle-room?roomNumber=${data.value.roomId}&fromResult=1`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({
|
uni.navigateBack();
|
||||||
url: '/pages/ranking',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,33 +102,42 @@ 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`);
|
||||||
|
|
||||||
if (!user.value.id && !token) {
|
if (!user.value.id && !token) {
|
||||||
try {
|
// showModal.value = true;
|
||||||
const wxResult = await uni.login({provider: "weixin"});
|
// try {
|
||||||
const bindResult = await checkUserBindAPI(wxResult.code);
|
// const wxResult = await uni.login({provider: "weixin"});
|
||||||
if (bindResult.binded) {
|
// const bindResult = await checkUserBindAPI(wxResult.code);
|
||||||
const newResult = await uni.login({provider: "weixin"});
|
// if (bindResult.binded) {
|
||||||
const silentResult = await silentLoginAPI(newResult.code);
|
// const newResult = await uni.login({provider: "weixin"});
|
||||||
if (silentResult.user) updateUser(silentResult.user);
|
// const silentResult = await silentLoginAPI(newResult.code);
|
||||||
const devices = await getMyDevicesAPI();
|
// if (silentResult.user) updateUser(silentResult.user);
|
||||||
if (devices.bindings && devices.bindings.length) {
|
// const devices = await getMyDevicesAPI();
|
||||||
updateDevice(
|
// if (devices.bindings && devices.bindings.length) {
|
||||||
devices.bindings[0].deviceId,
|
// updateDevice(
|
||||||
devices.bindings[0].deviceName
|
// devices.bindings[0].deviceId,
|
||||||
);
|
// devices.bindings[0].deviceName
|
||||||
const data = await getDeviceBatteryAPI();
|
// );
|
||||||
updateOnline(data.online);
|
// const data = await getDeviceBatteryAPI();
|
||||||
}
|
// updateOnline(data.online);
|
||||||
} else {
|
// }
|
||||||
showModal.value = true;
|
// } else {
|
||||||
}
|
// showModal.value = true;
|
||||||
} catch (e) {
|
// }
|
||||||
console.log("检查绑定状态失败", e);
|
// } catch (e) {
|
||||||
}
|
// console.log("检查绑定状态失败", e);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises = [getScoreRankList(undefined, 1, 10)];
|
const promises = [getScoreRankList(undefined, 1, 10)];
|
||||||
@@ -157,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
|
||||||
@@ -475,7 +537,7 @@ onShareTimeline(() => {
|
|||||||
|
|
||||||
.top-theme {
|
.top-theme {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const handleMatchTimeout = async () => {
|
|||||||
if (battle && battle.matchId) {
|
if (battle && battle.matchId) {
|
||||||
uni.showToast({ title: "匹配成功,正在进入...", icon: "none" });
|
uni.showToast({ title: "匹配成功,正在进入...", icon: "none" });
|
||||||
if (battle.mode <= 3) {
|
if (battle.mode <= 3) {
|
||||||
uni.redirectTo({ url: `/pages/team-battle?battleId=${battle.matchId}` });
|
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ async function cancelMatch() {
|
|||||||
const battle = await getBattleAPI();
|
const battle = await getBattleAPI();
|
||||||
if (battle && battle.matchId) {
|
if (battle && battle.matchId) {
|
||||||
if (battle.mode <= 3) {
|
if (battle.mode <= 3) {
|
||||||
uni.redirectTo({ url: `/pages/team-battle?battleId=${battle.matchId}` });
|
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ async function onReceiveMessage(msg) {
|
|||||||
// mode <= 3 为团队对抗,mode > 3 为大乱斗,覆盖全部 gameType(1~5),不再遗漏
|
// mode <= 3 为团队对抗,mode > 3 为大乱斗,覆盖全部 gameType(1~5),不再遗漏
|
||||||
if (msg.mode <= 3) {
|
if (msg.mode <= 3) {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: `/pages/team-battle?battleId=${msg.id}`,
|
url: `/pages/team-battle/index?battleId=${msg.id}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -117,7 +117,7 @@ async function onComplete() {
|
|||||||
start.value = false;
|
start.value = false;
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
||||||
const result = await createPractiseAPI(total, 360);
|
const result = await createPractiseAPI(total, 3600);
|
||||||
if (result) practiseId.value = result.id;
|
if (result) practiseId.value = result.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
uni.$on("share-image", onClickShare);
|
uni.$on("share-image", onClickShare);
|
||||||
const result = await createPractiseAPI(total, 360, targetType.value);
|
const result = await createPractiseAPI(total, 3600, targetType.value);
|
||||||
if (result) practiseId.value = result.id;
|
if (result) practiseId.value = result.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ onBeforeUnmount(() => {
|
|||||||
<ShootProgress
|
<ShootProgress
|
||||||
:tips="`请连续射${total}支箭`"
|
:tips="`请连续射${total}支箭`"
|
||||||
:start="start"
|
:start="start"
|
||||||
:total="360"
|
:total="3600"
|
||||||
:onStop="onOver"
|
:onStop="onOver"
|
||||||
/>
|
/>
|
||||||
<view class="user-row">
|
<view class="user-row">
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ onShow(async () => {
|
|||||||
>
|
>
|
||||||
<text>{{ seasonName }}</text>
|
<text>{{ seasonName }}</text>
|
||||||
<image
|
<image
|
||||||
|
class="triangle-icon"
|
||||||
v-show="seasonData.length > 1"
|
v-show="seasonData.length > 1"
|
||||||
src="../static/rank/triangle.png"
|
src="../static/rank/triangle.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
@@ -592,6 +593,7 @@ onShow(async () => {
|
|||||||
.ranking-season > image {
|
.ranking-season > image {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
transform: rotateX(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-season > text {
|
.ranking-season > text {
|
||||||
@@ -807,18 +809,18 @@ onShow(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.season-list {
|
.season-list {
|
||||||
background-color: #000c;
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 5px 0;
|
padding: 12px 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 220rpx;
|
width: 220rpx;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 400rpx;
|
max-height: 400rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
top: -44rpx;
|
top: -42rpx;
|
||||||
right: -30rpx;
|
right: -30rpx;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -829,7 +831,7 @@ onShow(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
padding: 20rpx 0;
|
padding: 20rpx 10rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.season-list > view > text {
|
.season-list > view > text {
|
||||||
|
|||||||
91
src/pages/team-battle/components/AppBackground.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { capsuleHeight } from "@/util";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#050b19",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="background" :style="{ backgroundColor: bgColor }">
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 0"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 1"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg2.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 2"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg3.png"
|
||||||
|
:style="{ height: capsuleHeight + 50 + 'px' }"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 3"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg4.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 4"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg5.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 5"
|
||||||
|
src="https://static.shelingxingqiu.com/attachment/2026-01-05/dfgf3b5kp459tfyn0f.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 6"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view class="bg-overlay" v-if="type === 0"></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.background {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-image {
|
||||||
|
width: 100%;
|
||||||
|
/* height: 100%; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-overlay {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(26, 26, 26, 0.2),
|
||||||
|
rgba(0, 0, 0, 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/pages/team-battle/components/Avatar.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from "vue";
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
const store = useStore();
|
||||||
|
const { getLvlImage } = store;
|
||||||
|
const { config } = storeToRefs(store);
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
rankLvl: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
onClick: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
rank: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 45,
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const avatarFrame = ref("");
|
||||||
|
watch(
|
||||||
|
() => [config.value, props.rankLvl],
|
||||||
|
() => {
|
||||||
|
if (props.rankLvl !== undefined) {
|
||||||
|
avatarFrame.value = getLvlImage(props.rankLvl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="avatar" @click="onClick">
|
||||||
|
<image
|
||||||
|
v-if="avatarFrame"
|
||||||
|
:src="avatarFrame"
|
||||||
|
mode="widthFix"
|
||||||
|
:style="{
|
||||||
|
width: Number(size) + 10 + 'px',
|
||||||
|
height: Number(size) + 10 + 'px',
|
||||||
|
}"
|
||||||
|
class="avatar-frame"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="rank === 1"
|
||||||
|
src="../../../static/champ1.png"
|
||||||
|
mode="widthFix"
|
||||||
|
class="avatar-rank"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="rank === 2"
|
||||||
|
src="../../../static/champ2.png"
|
||||||
|
mode="widthFix"
|
||||||
|
class="avatar-rank"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="rank === 3"
|
||||||
|
src="../../../static/champ3.png"
|
||||||
|
mode="widthFix"
|
||||||
|
class="avatar-rank"
|
||||||
|
/>
|
||||||
|
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||||
|
<image
|
||||||
|
:src="src || '../../../static/user-icon.png'"
|
||||||
|
mode="widthFix"
|
||||||
|
:style="{
|
||||||
|
width: size + 'px',
|
||||||
|
height: size + 'px',
|
||||||
|
minHeight: size + 'px',
|
||||||
|
borderColor: borderColor || '#fff',
|
||||||
|
}"
|
||||||
|
class="avatar-image"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.avatar-frame {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.avatar-rank,
|
||||||
|
.rank-view {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 15px;
|
||||||
|
top: -6px;
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
.rank-view {
|
||||||
|
background-color: #6d6d6d;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top-left-radius: 50%;
|
||||||
|
border-bottom-right-radius: 50%;
|
||||||
|
}
|
||||||
|
.avatar-image {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
138
src/pages/team-battle/components/BackToGame.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
|
||||||
|
import { getBattleAPI, getUserGameState } from "@/apis";
|
||||||
|
import { debounce } from "@/util";
|
||||||
|
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
const store = useStore();
|
||||||
|
const { user, game } = storeToRefs(store);
|
||||||
|
const { updateGame } = store;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
signin: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
|
||||||
|
const getToken = () =>
|
||||||
|
uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
if (user.value.id && getToken()) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const state = await getUserGameState();
|
||||||
|
updateGame(state.gaming, state.roomId);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => user.value,
|
||||||
|
async (value) => {
|
||||||
|
if (!value.id) {
|
||||||
|
updateGame(false, "");
|
||||||
|
} else if (getToken()) {
|
||||||
|
// 有有效 token 时才查询在局状态,避免 token 失效时反复发起无效请求
|
||||||
|
const state = await getUserGameState();
|
||||||
|
updateGame(state.gaming, state.roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = debounce(async () => {
|
||||||
|
if (loading.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}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/melee-battle?battleId=${result.matchId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (game.value.roomID) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/battle-room?roomNumber=" + game.value.roomID,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateGame(false, "");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const gameOver = () => {
|
||||||
|
updateGame(false, "");
|
||||||
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("game-over", gameOver);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
uni.$off("game-over", gameOver);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view
|
||||||
|
v-if="game.inBattle || game.roomID"
|
||||||
|
class="back-to-game"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<image src="../../../static/back-to-game-bg.png" mode="widthFix" />
|
||||||
|
<block v-if="game.inBattle">
|
||||||
|
<image src="../../../static/pk-icon.png" mode="widthFix" />
|
||||||
|
<text>返回进行中的对局</text>
|
||||||
|
</block>
|
||||||
|
<block v-else-if="game.roomID">
|
||||||
|
<text>返回房间</text>
|
||||||
|
</block>
|
||||||
|
<image src="../../../static/back.png" mode="widthFix" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-to-game {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
width: 56vw;
|
||||||
|
left: calc(50% - 28vw);
|
||||||
|
top: 12%;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.back-to-game > image:first-child {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100rpx;
|
||||||
|
}
|
||||||
|
.back-to-game > image:nth-child(2) {
|
||||||
|
position: relative;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.back-to-game > text {
|
||||||
|
position: relative;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.back-to-game > image:last-child {
|
||||||
|
position: relative;
|
||||||
|
width: 15px;
|
||||||
|
margin-left: 5px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
203
src/pages/team-battle/components/BattleFooter.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
=
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import BowPower from "./BowPower.vue";
|
||||||
|
import { RoundImages } from "@/constants";
|
||||||
|
const props = defineProps({
|
||||||
|
roundResults: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
bluePoints: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
redPoints: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
goldenRound: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalRounds = computed(() => {
|
||||||
|
const count = props.roundResults.findIndex((item) => !!item.ifGold);
|
||||||
|
return count > 0 ? count : props.roundResults.length;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="guide-row">
|
||||||
|
<image src="../../../static/shooter.png" mode="widthFix" />
|
||||||
|
<view
|
||||||
|
:style="{
|
||||||
|
marginBottom: '10px',
|
||||||
|
transform: 'scale(0.8) translateX(10px)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<BowPower />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<image src="../../../static/battle-header-melee.png" mode="widthFix" />
|
||||||
|
<text>蓝队({{ bluePoints }})</text>
|
||||||
|
<text>红队({{ redPoints }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="players">
|
||||||
|
<view>
|
||||||
|
<view v-for="(result, index) in roundResults" :key="index">
|
||||||
|
<block v-if="index + 1 > normalRounds">
|
||||||
|
<image
|
||||||
|
:src="RoundImages[`gold${index + 1 - normalRounds}`]"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
|
<block v-else>
|
||||||
|
<image :src="RoundImages[`round${index + 1}`]" mode="widthFix" />
|
||||||
|
</block>
|
||||||
|
<view>
|
||||||
|
<text>{{
|
||||||
|
result.shoots[1] && result.shoots[1].length
|
||||||
|
? result.shoots[1]
|
||||||
|
.map((item) => item.ring)
|
||||||
|
.reduce((last, next) => last + next, 0)
|
||||||
|
: ""
|
||||||
|
}}</text>
|
||||||
|
<text>环</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<block v-if="roundResults.length < 3">
|
||||||
|
<view v-for="i in 3 - roundResults.length" :key="i">
|
||||||
|
<image
|
||||||
|
:src="RoundImages[`round${i + roundResults.length}`]"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view>
|
||||||
|
<text></text>
|
||||||
|
<text>环</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<view v-for="(result, index) in roundResults" :key="index">
|
||||||
|
<block v-if="index + 1 > normalRounds">
|
||||||
|
<image
|
||||||
|
:src="RoundImages[`gold${index + 1 - normalRounds}`]"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
|
<block v-else>
|
||||||
|
<image :src="RoundImages[`round${index + 1}`]" mode="widthFix" />
|
||||||
|
</block>
|
||||||
|
<view>
|
||||||
|
<text>{{
|
||||||
|
result.shoots[2] && result.shoots[2].length
|
||||||
|
? result.shoots[2]
|
||||||
|
.map((item) => item.ring)
|
||||||
|
.reduce((last, next) => last + next, 0)
|
||||||
|
: ""
|
||||||
|
}}</text>
|
||||||
|
<text>环</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<block v-if="roundResults.length < 3">
|
||||||
|
<view v-for="i in 3 - roundResults.length" :key="i">
|
||||||
|
<image
|
||||||
|
:src="RoundImages[`round${i + roundResults.length}`]"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view>
|
||||||
|
<text></text>
|
||||||
|
<text>环</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: -100rpx;
|
||||||
|
}
|
||||||
|
.container > view:nth-child(2) {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.container > view:nth-child(2) > image:first-child {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
.container > view:nth-child(2) > text {
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #8a323e;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.container > view:nth-child(2) > text:nth-child(2) {
|
||||||
|
color: #004ac1;
|
||||||
|
}
|
||||||
|
.players {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.players > view {
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
.players > view:first-child > view {
|
||||||
|
background: linear-gradient(270deg, #172a86 0%, #0000 100%);
|
||||||
|
}
|
||||||
|
.players > view:last-child > view {
|
||||||
|
background: linear-gradient(270deg, #0000 0%, #6a1212 100%);
|
||||||
|
}
|
||||||
|
.players > view > view {
|
||||||
|
min-height: 52rpx;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
padding: 2px 20px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.players > view > view > image:first-child {
|
||||||
|
width: 135rpx;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.players > view > view > view:last-child {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.players > view > view > view:last-child > text:first-child {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fed847;
|
||||||
|
margin-right: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.guide-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 5vw;
|
||||||
|
margin-bottom: -4px;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.guide-row > image {
|
||||||
|
width: 140rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
200
src/pages/team-battle/components/BattleHeader.vue
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup>
|
||||||
|
import Avatar from "./Avatar.vue";
|
||||||
|
import { meleeAvatarColors } from "@/constants";
|
||||||
|
defineProps({
|
||||||
|
players: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
blueTeam: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
redTeam: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
showRank: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
winner: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
showHeader: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container" :style="{ paddingTop: showHeader ? '5px' : '0' }">
|
||||||
|
<image
|
||||||
|
v-if="showHeader"
|
||||||
|
:src="`../../../static/battle-header${players.length ? '-melee' : ''}.png`"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-if="!players.length && blueTeam.length && redTeam.length"
|
||||||
|
class="players"
|
||||||
|
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<view
|
||||||
|
v-for="(player, index) in blueTeam"
|
||||||
|
:key="index"
|
||||||
|
:style="{
|
||||||
|
margin: blueTeam.length === 2 ? '0 -5px' : '0 6px',
|
||||||
|
width: `${100 / blueTeam.length - blueTeam.length * 3}%`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||||
|
<text class="player-name">{{ player.name }}</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
v-if="winner === 1"
|
||||||
|
src="../../../static/winner-badge.png"
|
||||||
|
mode="widthFix"
|
||||||
|
class="left-winner-badge"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<view
|
||||||
|
v-for="(player, index) in redTeam"
|
||||||
|
:key="index"
|
||||||
|
:style="{
|
||||||
|
margin: redTeam.length === 2 ? '0 -5px' : '0 6px',
|
||||||
|
width: `${100 / redTeam.length - redTeam.length * 3}%`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||||
|
<text class="player-name">{{ player.name }}</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
v-if="winner === 2"
|
||||||
|
src="../../../static/winner-badge.png"
|
||||||
|
mode="widthFix"
|
||||||
|
class="right-winner-badge"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 大乱斗玩家列表:scroll-view 作为横向滚动容器 -->
|
||||||
|
<!-- 小程序中 scroll-view 不支持直接 display:flex,需内部 wrapper view 承载 flex 布局 -->
|
||||||
|
<!-- 仅当玩家 >5 人(内容溢出宽度)时才阻止冒泡,防止与外层 swiper 切换 tab 的手势冲突 -->
|
||||||
|
<scroll-view
|
||||||
|
v-if="players.length"
|
||||||
|
class="players-melee"
|
||||||
|
scroll-x
|
||||||
|
@touchmove="(e) => players.length > 5 && e.stopPropagation()"
|
||||||
|
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||||
|
>
|
||||||
|
<view class="players-melee-inner">
|
||||||
|
<view
|
||||||
|
v-for="(player, index) in players"
|
||||||
|
:key="index"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: meleeAvatarColors[index],
|
||||||
|
width: `${Math.max(100 / players.length, 18)}vw`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="player.avatar"
|
||||||
|
:rankLvl="showRank ? undefined : player.rankLvl"
|
||||||
|
:size="40"
|
||||||
|
:rank="showRank ? index + 1 : 0"
|
||||||
|
/>
|
||||||
|
<text class="player-name">{{ player.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.container > image:first-child {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: -5px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.players {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.players > view {
|
||||||
|
width: 50%;
|
||||||
|
height: 75px;
|
||||||
|
color: #fff9;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.players > view:first-child {
|
||||||
|
background-color: #364469;
|
||||||
|
}
|
||||||
|
.players > view:last-child {
|
||||||
|
background-color: #692735;
|
||||||
|
}
|
||||||
|
.players > view > view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.players-melee {
|
||||||
|
height: 80px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.players-melee::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
|
||||||
|
.players-melee-inner {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.players-melee-inner > view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff9;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-top: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.player-name {
|
||||||
|
margin-top: 3px;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.left-winner-badge {
|
||||||
|
position: absolute;
|
||||||
|
width: 50px;
|
||||||
|
top: -12%;
|
||||||
|
left: -5%;
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
.right-winner-badge {
|
||||||
|
position: absolute;
|
||||||
|
width: 50px;
|
||||||
|
top: -12%;
|
||||||
|
right: -5%;
|
||||||
|
transform: rotate(36deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/pages/team-battle/components/BowPower.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { getDeviceBatteryAPI } from "@/apis";
|
||||||
|
|
||||||
|
const power = ref(0);
|
||||||
|
const timer = ref(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await getDeviceBatteryAPI();
|
||||||
|
power.value = data.battery;
|
||||||
|
timer.value = setInterval(async () => {
|
||||||
|
const data = await getDeviceBatteryAPI();
|
||||||
|
power.value = data.battery;
|
||||||
|
}, 1000 * 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timer.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<image src="../../../static/b-power.png" mode="widthFix" />
|
||||||
|
<view>电量{{ power || 1 }}%</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffffa8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.container > image {
|
||||||
|
width: 20px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
451
src/pages/team-battle/components/BowTarget.vue
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||||
|
import PointSwitcher from "./PointSwitcher.vue";
|
||||||
|
|
||||||
|
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import { simulShootAPI } from "@/apis";
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
const store = useStore();
|
||||||
|
const { user, device } = storeToRefs(store);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
currentRound: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
totalRound: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
scores: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
blueScores: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
latestShotFlash: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "solo", // solo 单排,team 双排
|
||||||
|
},
|
||||||
|
stop: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pMode = ref(true);
|
||||||
|
const latestOne = ref(null);
|
||||||
|
const bluelatestOne = ref(null);
|
||||||
|
const timer = ref(null);
|
||||||
|
const dirTimer = ref(null);
|
||||||
|
const angle = ref(null);
|
||||||
|
const circleColor = ref("");
|
||||||
|
|
||||||
|
function showShotFlash(flash) {
|
||||||
|
const shootData = flash?.shootData;
|
||||||
|
if (!shootData) return;
|
||||||
|
if (timer.value) clearTimeout(timer.value);
|
||||||
|
|
||||||
|
if (flash.team === "red") {
|
||||||
|
latestOne.value = shootData;
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
latestOne.value = null;
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bluelatestOne.value = shootData;
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
bluelatestOne.value = null;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.latestShotFlash,
|
||||||
|
(newVal) => {
|
||||||
|
showShotFlash(newVal);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function calcRealX(num, offset = 3.4) {
|
||||||
|
const len = 20.4 + num;
|
||||||
|
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||||
|
}
|
||||||
|
function calcRealY(num, offset = 3.4) {
|
||||||
|
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num;
|
||||||
|
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||||
|
}
|
||||||
|
const simulShoot = async () => {
|
||||||
|
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||||
|
};
|
||||||
|
const simulShoot2 = async () => {
|
||||||
|
if (device.value.deviceId) {
|
||||||
|
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
||||||
|
await simulShootAPI(device.value.deviceId, r1, r1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = computed(() => {
|
||||||
|
const accountInfo = uni.getAccountInfoSync();
|
||||||
|
return accountInfo.miniProgram.envVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrowStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
transform: `rotateX(180deg) translate(-50%, -50%) rotate(${
|
||||||
|
360 - angle.value
|
||||||
|
}deg) translateY(105%)`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onReceiveMessage(message) {
|
||||||
|
if (Array.isArray(message)) return;
|
||||||
|
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
||||||
|
if (
|
||||||
|
message.shootData.playerId === user.value.id &&
|
||||||
|
!message.shootData.ring &&
|
||||||
|
message.shootData.angle >= 0
|
||||||
|
) {
|
||||||
|
angle.value = null;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (props.scores[0]) {
|
||||||
|
circleColor.value =
|
||||||
|
message.shootData.playerId === props.scores[0].playerId
|
||||||
|
? "#ff4444"
|
||||||
|
: "#1840FF";
|
||||||
|
}
|
||||||
|
angle.value = message.shootData.angle;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
timer.value = null;
|
||||||
|
}
|
||||||
|
if (dirTimer.value) {
|
||||||
|
clearTimeout(dirTimer.value);
|
||||||
|
dirTimer.value = null;
|
||||||
|
}
|
||||||
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header" v-if="totalRound > 0">
|
||||||
|
<text v-if="totalRound > 0" class="round-count">{{
|
||||||
|
(currentRound > totalRound ? totalRound : currentRound) +
|
||||||
|
"/" +
|
||||||
|
totalRound
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="target">
|
||||||
|
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||||
|
<view :style="{ background: circleColor }">
|
||||||
|
<image src="../../../static/dot-circle.png" mode="widthFix" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="stop" class="stop-sign">中场休息</view>
|
||||||
|
<view
|
||||||
|
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||||
|
class="e-value fade-in-out"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
||||||
|
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
经验 +1
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="latestOne"
|
||||||
|
class="round-tip fade-in-out"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
||||||
|
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
||||||
|
}"
|
||||||
|
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
||||||
|
}}<text v-if="latestOne.ring">环</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="
|
||||||
|
bluelatestOne &&
|
||||||
|
bluelatestOne.ring &&
|
||||||
|
user.id === bluelatestOne.playerId
|
||||||
|
"
|
||||||
|
class="e-value fade-in-out"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
|
||||||
|
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
经验 +1
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="bluelatestOne"
|
||||||
|
class="round-tip fade-in-out"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
|
||||||
|
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
|
||||||
|
}"
|
||||||
|
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
||||||
|
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||||
|
>
|
||||||
|
<block v-for="(bow, index) in scores" :key="index">
|
||||||
|
<view
|
||||||
|
v-if="bow.ring > 0"
|
||||||
|
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||||
|
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||||
|
}`"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||||
|
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||||
|
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||||
|
}"
|
||||||
|
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||||
|
>
|
||||||
|
</block>
|
||||||
|
<block v-for="(bow, index) in blueScores" :key="index">
|
||||||
|
<view
|
||||||
|
v-if="bow.ring > 0"
|
||||||
|
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||||
|
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||||
|
}`"
|
||||||
|
:style="{
|
||||||
|
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||||
|
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||||
|
backgroundColor: '#1840FF',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<text v-if="pMode">{{ index + 1 }}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<image src="../../../static/bow-target.png" mode="widthFix" />
|
||||||
|
</view>
|
||||||
|
<view class="footer">
|
||||||
|
<PointSwitcher
|
||||||
|
:onChange="(val) => (pMode = val)"
|
||||||
|
:style="{ zIndex: 999 }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="simul" v-if="env !== 'release'">
|
||||||
|
<button @click="simulShoot">模拟</button>
|
||||||
|
<button @click="simulShoot2">射箭</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: calc(100vw - 30px);
|
||||||
|
height: calc(100vw - 30px);
|
||||||
|
padding: 0px 15px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.target {
|
||||||
|
position: relative;
|
||||||
|
margin: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.e-value {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #0006;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.round-tip {
|
||||||
|
position: absolute;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.round-tip > text {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.target > image:last-child {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.hit {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.s-point {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
min-width: 4px;
|
||||||
|
min-height: 4px;
|
||||||
|
}
|
||||||
|
.b-point {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
min-width: 10px;
|
||||||
|
min-height: 10px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.b-point > text {
|
||||||
|
font-size: 16rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-family: "DINCondensed";
|
||||||
|
/* text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);*/
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: -40px;
|
||||||
|
}
|
||||||
|
.header > image:first-child {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.round-count {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fed847;
|
||||||
|
top: 75px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
margin-top: -40px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.footer > image {
|
||||||
|
width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
.simul {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 20px;
|
||||||
|
margin-left: 20px;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.simul > button {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.stop-sign {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 44px;
|
||||||
|
color: #fff9;
|
||||||
|
text-align: center;
|
||||||
|
width: 200px;
|
||||||
|
height: 60px;
|
||||||
|
left: calc(50% - 100px);
|
||||||
|
top: calc(50% - 30px);
|
||||||
|
z-index: 99;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.arrow-dir {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 52%;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.arrow-dir > view {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.arrow-dir > view > image {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
transform: translate(-30%, -30%);
|
||||||
|
}
|
||||||
|
@keyframes spring-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: scale(3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: scale(3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: scale(3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes disappear {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.arrow-dir > view {
|
||||||
|
animation: disappear 3s ease forwards;
|
||||||
|
}
|
||||||
|
.arrow-dir > view > image {
|
||||||
|
animation: spring-in 3s ease forwards;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
353
src/pages/team-battle/components/Container.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import AppBackground from "./AppBackground.vue";
|
||||||
|
import Header from "./Header.vue";
|
||||||
|
import ScreenHint from "./ScreenHint.vue";
|
||||||
|
import BackToGame from "./BackToGame.vue";
|
||||||
|
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||||
|
import { capsuleHeight, debounce } from "@/util";
|
||||||
|
import AudioManager from "@/audioManager";
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
bgType: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
onBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
isHome: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showBackToGame: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#050b19",
|
||||||
|
},
|
||||||
|
whiteBackArrow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showBottom: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||||
|
const showHint = ref(false);
|
||||||
|
const hintType = ref(0);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const audioInitProgress = ref(1);
|
||||||
|
const audioProgress = ref(0);
|
||||||
|
const audioTimer = ref(null);
|
||||||
|
|
||||||
|
const showGlobalHint = (type) => {
|
||||||
|
hintType.value = type;
|
||||||
|
showHint.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideGlobalHint = () => {
|
||||||
|
showHint.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
uni.restartMiniProgram({
|
||||||
|
path: "/pages/index",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAudioProgress = async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
audioInitProgress.value = AudioManager.getLoadProgress();
|
||||||
|
if (audioInitProgress.value === 1) return resolve();
|
||||||
|
audioTimer.value = setInterval(() => {
|
||||||
|
audioProgress.value = AudioManager.getLoadProgress();
|
||||||
|
if (audioProgress.value === 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
audioInitProgress.value = 1;
|
||||||
|
}, 200);
|
||||||
|
clearInterval(audioTimer.value);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioFinalProgress = computed(() => {
|
||||||
|
const left = 1 - audioInitProgress.value;
|
||||||
|
if (left <= 0) return 0;
|
||||||
|
return Math.max(0, (audioProgress.value - audioInitProgress.value) / left);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingProgress = computed(() => {
|
||||||
|
if (props.loading && audioInitProgress.value >= 1) return 1;
|
||||||
|
return audioFinalProgress.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (audioTimer.value) clearInterval(audioTimer.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
uni.$showHint = showGlobalHint;
|
||||||
|
uni.$hideHint = hideGlobalHint;
|
||||||
|
uni.$checkAudio = checkAudioProgress;
|
||||||
|
showHint.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const backToGame = debounce(async () => {
|
||||||
|
if (isLoading.value) return; // 防止重复点击
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
const result = await getBattleAPI();
|
||||||
|
if (result && result.matchId) {
|
||||||
|
await checkAudioProgress();
|
||||||
|
if (result.mode <= 3) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/melee-battle?battleId=${result.matchId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取当前游戏失败:", error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
uni.navigateBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelMatching = async () => {
|
||||||
|
uni.$emit("cancelMatching");
|
||||||
|
}
|
||||||
|
|
||||||
|
const goCalibration = async () => {
|
||||||
|
await laserAimAPI();
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/calibration",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
||||||
|
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||||
|
<Header
|
||||||
|
v-if="!isHome"
|
||||||
|
:title="title"
|
||||||
|
:onBack="onBack"
|
||||||
|
:whiteBackArrow="whiteBackArrow"
|
||||||
|
/>
|
||||||
|
<BackToGame v-if="showBackToGame" />
|
||||||
|
<scroll-view
|
||||||
|
:scroll-y="scroll"
|
||||||
|
:enhanced="true"
|
||||||
|
:bounces="false"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:style="{
|
||||||
|
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
||||||
|
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||||
|
})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</scroll-view>
|
||||||
|
<view
|
||||||
|
class="bottom-part"
|
||||||
|
v-if="$slots.bottom && showBottom"
|
||||||
|
:style="{ height: isIOS ? '65px' : '55px', paddingTop: '10px' }"
|
||||||
|
>
|
||||||
|
<slot name="bottom"></slot>
|
||||||
|
</view>
|
||||||
|
<ScreenHint :show="showHint">
|
||||||
|
<view v-if="hintType === 1" class="tip-content">
|
||||||
|
<text>完成进行中的对局才能开启新的。</text>
|
||||||
|
<text>您有正在进行中的对局,是否进入?</text>
|
||||||
|
<view>
|
||||||
|
<button hover-class="none" @click="() => (showHint = false)">
|
||||||
|
不进入
|
||||||
|
</button>
|
||||||
|
<button hover-class="none" @click="backToGame" :disabled="isLoading">
|
||||||
|
{{ isLoading ? "加载中..." : "进入" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="hintType === 2" class="tip-content">
|
||||||
|
<text>离开比赛可能会导致比赛失败,</text>
|
||||||
|
<text>确认离开吗?</text>
|
||||||
|
<view>
|
||||||
|
<button hover-class="none" @click="goBack">离开比赛</button>
|
||||||
|
<button hover-class="none" @click="() => (showHint = false)">
|
||||||
|
继续比赛
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="hintType === 3" class="tip-content">
|
||||||
|
<text>今天不玩了吗?</text>
|
||||||
|
<view>
|
||||||
|
<button hover-class="none" @click="() => (showHint = false)">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button hover-class="none" @click="$clickSound(cancelMatching)">确认</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="hintType === 4" class="tip-content">
|
||||||
|
<text>完成智能弓校准,即可解锁全部功能</text>
|
||||||
|
<view>
|
||||||
|
<button hover-class="none" @click="() => (showHint = false)">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button hover-class="none" @click="goCalibration">去校准</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</ScreenHint>
|
||||||
|
<view v-if="loading || audioInitProgress < 1" class="audio-progress">
|
||||||
|
<image
|
||||||
|
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view>
|
||||||
|
<view :style="{ width: `${loadingProgress * 100}%` }">
|
||||||
|
<!-- <image
|
||||||
|
src="https://static.shelingxingqiu.com/attachment/2025-11-24/degu91a7si77sg9jqv.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/> -->
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text>{{ loading ? loadingText || "加载中..." : "若加载时间过长,请" }}</text>
|
||||||
|
<button v-if="!loading" hover-class="none" @click="restart">点击这里重启</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tip-content {
|
||||||
|
flex-direction: column;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.tip-content > text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tip-content > view {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-top: 50rpx;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tip-content > view > button {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: #fff6;
|
||||||
|
color: #fff;
|
||||||
|
width: 45%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.tip-content > view > button:last-child {
|
||||||
|
background-color: #fed847;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-content > view > button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
color: #666;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.audio-progress {
|
||||||
|
z-index: 999;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: rgb(0 0 0 / 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.audio-progress > image:nth-child(1) {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(2) {
|
||||||
|
width: 380rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: #595959;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(2) > view {
|
||||||
|
background: #ffe431;
|
||||||
|
min-height: 6rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(2) > view > image {
|
||||||
|
width: 46rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(3) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(3) > text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a2a2a2;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 32rpx;
|
||||||
|
}
|
||||||
|
.audio-progress > view:nth-child(3) > button {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #ffe431;
|
||||||
|
line-height: 32rpx;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
src/pages/team-battle/components/Guide.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
noBg: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bubbleTypes = [
|
||||||
|
"../../../static/long-bubble.png",
|
||||||
|
"../../../static/long-bubble-middle.png",
|
||||||
|
"../../../static/long-bubble-tall.png",
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<image src="../../../static/shooter.png" mode="widthFix" />
|
||||||
|
<view>
|
||||||
|
<image
|
||||||
|
v-if="!noBg"
|
||||||
|
:src="bubbleTypes[type]"
|
||||||
|
:style="{ top: type === 2 ? '-6%' : '-13%' }"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
width: clac(100% - 30px);
|
||||||
|
}
|
||||||
|
.container > image {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
.container > view {
|
||||||
|
position: relative;
|
||||||
|
width: 80%;
|
||||||
|
min-height: 55px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.container > view > image {
|
||||||
|
position: absolute;
|
||||||
|
left: -7%;
|
||||||
|
width: 108%;
|
||||||
|
}
|
||||||
|
.container > view {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
328
src/pages/team-battle/components/Header.vue
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import HeaderProgress from "./HeaderProgress.vue";
|
||||||
|
import Avatar from "./Avatar.vue";
|
||||||
|
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
const store = useStore();
|
||||||
|
const { user, game } = storeToRefs(store);
|
||||||
|
|
||||||
|
const currentPage = computed(() => {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
return pages[pages.length - 1].route;
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
onBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
whiteBackArrow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (props.onBack) {
|
||||||
|
props.onBack();
|
||||||
|
} else {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack();
|
||||||
|
} else {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/index",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUserPage = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/user",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const signin = () => {
|
||||||
|
if (!user.value.id) {
|
||||||
|
uni.$emit("point-book-signin");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const pointBook = ref(null);
|
||||||
|
const heat = ref(0);
|
||||||
|
/** 房间号按钮动态定位样式(position: fixed,根据胶囊真实位置计算,脱离 flex 流避免挤压标题) */
|
||||||
|
const battleRoomBtnStyle = ref({});
|
||||||
|
|
||||||
|
const updateLoading = (value) => {
|
||||||
|
loading.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHot = (value) => {
|
||||||
|
heat.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const currentPage = pages[pages.length - 1];
|
||||||
|
if (
|
||||||
|
currentPage.route === "pages/point-book-edit" ||
|
||||||
|
currentPage.route === "pages/point-book-detail"
|
||||||
|
) {
|
||||||
|
pointBook.value = uni.getStorageSync("point-book");
|
||||||
|
if (!pointBook.value) {
|
||||||
|
pointBook.value = uni.getStorageSync("last-point-book");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 仅在对战房间页获取胶囊位置,按钮用 fixed 定位精确贴靠胶囊左侧(脱离 flex 流,不挤压标题)
|
||||||
|
if (currentPage.route === "pages/battle-room") {
|
||||||
|
try {
|
||||||
|
const menuButtonRect = uni.getMenuButtonBoundingClientRect();
|
||||||
|
const { windowWidth } = uni.getSystemInfoSync();
|
||||||
|
battleRoomBtnStyle.value = {
|
||||||
|
// 按钮右边缘距视口右侧 = 屏幕宽 - 胶囊左边缘 + 4px 安全间隙
|
||||||
|
right: (windowWidth - menuButtonRect.left + 4) + "px",
|
||||||
|
// 垂直位置与胶囊顶部对齐
|
||||||
|
top: menuButtonRect.top + "px",
|
||||||
|
// 高度与胶囊一致,视觉融合
|
||||||
|
height: menuButtonRect.height + "px",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// 获取失败时使用 CSS 兜底定位(28vw + 4px 作为 right,8px 作为 top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on("update-hot", updateHot);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
uni.$off("update-hot", updateHot);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="back-btn" @click="onClick">
|
||||||
|
<image v-if="whiteBackArrow" src="../../../static/back.png" mode="widthFix" />
|
||||||
|
<image
|
||||||
|
v-if="!whiteBackArrow"
|
||||||
|
src="../../../static/back-black.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view :style="{ color: whiteBackArrow ? '#fff' : '#000' }">
|
||||||
|
<view
|
||||||
|
v-if="currentPage === 'pages/point-book'"
|
||||||
|
class="user-header"
|
||||||
|
@click="signin"
|
||||||
|
>
|
||||||
|
<block v-if="user.id">
|
||||||
|
<Avatar
|
||||||
|
:src="user.avatar"
|
||||||
|
:onClick="toUserPage"
|
||||||
|
:size="40"
|
||||||
|
borderColor="#333"
|
||||||
|
/>
|
||||||
|
<text class="truncate">{{ user.nickName }}</text>
|
||||||
|
<image
|
||||||
|
v-if="heat"
|
||||||
|
:src="`../../../static/hot${heat}.png`"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
|
<block v-else>
|
||||||
|
<image src="../../../static/user-icon.png" mode="widthFix" />
|
||||||
|
<text>新来的弓箭手你好呀~</text>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<block
|
||||||
|
v-if="
|
||||||
|
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||||
|
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<text>{{ title }}</text>
|
||||||
|
</block>
|
||||||
|
<block
|
||||||
|
v-if="
|
||||||
|
title &&
|
||||||
|
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||||
|
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<view class="first-try-steps">
|
||||||
|
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text>
|
||||||
|
<text>-</text>
|
||||||
|
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
||||||
|
>感知距离</text
|
||||||
|
>
|
||||||
|
<text>-</text>
|
||||||
|
<text :class="title === '-小试牛刀' ? 'current-step' : ''"
|
||||||
|
>小试牛刀</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view v-if="pointBook" class="point-book-info">
|
||||||
|
<text>{{ pointBook.bowType.name }}</text>
|
||||||
|
<text>{{ pointBook.distance }} 米</text>
|
||||||
|
<text
|
||||||
|
>{{
|
||||||
|
pointBook.bowtargetType.name.substring(
|
||||||
|
0,
|
||||||
|
pointBook.bowtargetType.name.length - 3
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{
|
||||||
|
pointBook.bowtargetType.name.substring(
|
||||||
|
pointBook.bowtargetType.name.length - 3
|
||||||
|
)
|
||||||
|
}}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="
|
||||||
|
currentPage === 'pages/team-battle' ||
|
||||||
|
currentPage === 'pages/team-battle/index'
|
||||||
|
"
|
||||||
|
class="battle-progress"
|
||||||
|
>
|
||||||
|
<HeaderProgress />
|
||||||
|
</view>
|
||||||
|
<!-- 对战房间:整个胶囊为分享按钮,房号从 Store 读取;fixed 定位紧靠系统胶囊左侧 -->
|
||||||
|
<button
|
||||||
|
v-if="currentPage === 'pages/battle-room' && game.roomNumber"
|
||||||
|
open-type="share"
|
||||||
|
hover-class="none"
|
||||||
|
class="battle-room-number"
|
||||||
|
:style="battleRoomBtnStyle"
|
||||||
|
>
|
||||||
|
<text class="battle-room-number__text">房号: {{ game.roomNumber }}</text>
|
||||||
|
<image src="../../../static/share2.png" mode="widthFix" class="battle-room-number__icon" />
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
width: 72vw;
|
||||||
|
height: 50px;
|
||||||
|
/* margin-top: var(--status-bar-height); */
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
.container > view:nth-child(2) {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.back-btn > image {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.first-try-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.first-try-steps > text {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.first-try-steps > text:nth-child(2),
|
||||||
|
.first-try-steps > text:nth-child(4) {
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
.current-step {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.point-book-info {
|
||||||
|
color: #333;
|
||||||
|
position: fixed;
|
||||||
|
width: 60%;
|
||||||
|
left: 20%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.point-book-info > text {
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
.battle-progress {
|
||||||
|
position: fixed;
|
||||||
|
width: 60%;
|
||||||
|
left: 20%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.user-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.user-header > image:first-child {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid #333;
|
||||||
|
}
|
||||||
|
.user-header > image:last-child {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
}
|
||||||
|
.user-header > text:nth-child(2) {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #333333;
|
||||||
|
margin: 0 20rpx;
|
||||||
|
max-width: 300rpx;
|
||||||
|
}
|
||||||
|
/* 对战房间:整个胶囊作为分享按钮,fixed 定位脱离 flex 流,紧贴系统胶囊左侧 */
|
||||||
|
.battle-room-number {
|
||||||
|
position: fixed;
|
||||||
|
/* 兜底定位(JS 获取胶囊位置失败时生效):约 28vw 对应胶囊区域左边缘 */
|
||||||
|
right: calc(28vw + 4px);
|
||||||
|
top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 240rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 96rpx;
|
||||||
|
border: 1rpx solid #5b5758;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* 重置 button 默认边框 */
|
||||||
|
.battle-room-number::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.battle-room-number__text {
|
||||||
|
width: 156rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 28rpx;
|
||||||
|
}
|
||||||
|
.battle-room-number__icon {
|
||||||
|
width: 25rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
src/pages/team-battle/components/HeaderProgress.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import audioManager from "@/audioManager";
|
||||||
|
import useStore from "@/store";
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
// 私有 HeaderProgress 只负责展示提示/箭数和静音开关。
|
||||||
|
// 报靶、轮到谁射箭、比赛结束等语音统一由 team-battle 页面队列播放,避免重复播报。
|
||||||
|
const tips = ref("");
|
||||||
|
const sound = ref(true);
|
||||||
|
const currentShot = ref(0);
|
||||||
|
const totalShot = ref(0);
|
||||||
|
|
||||||
|
const updateSound = () => {
|
||||||
|
sound.value = !sound.value;
|
||||||
|
audioManager.setMuted(!sound.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateTips = (newVal) => {
|
||||||
|
// 兼容页面通过 uni.$emit("update-tips") 触发的即时刷新。
|
||||||
|
tips.value = newVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 Pinia store 中 totalShot 变化,用于比赛恢复时同步箭数(替代 uni.$emit 避免时序问题)
|
||||||
|
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决重入时 totalShot 值不变 watch 不触发的问题)
|
||||||
|
watch(
|
||||||
|
() => [store.game.currentShot, store.game.totalShot],
|
||||||
|
([newCurrentShot, newTotalShot]) => {
|
||||||
|
currentShot.value = newCurrentShot || 0;
|
||||||
|
totalShot.value = newTotalShot || 0;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听 Pinia store 中 tips 变化,用于比赛恢复时同步提示文案(替代 uni.$emit 避免时序问题)
|
||||||
|
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决 onShow 早于 onMounted 导致 uni.$emit 事件丢失的问题)
|
||||||
|
// 注意:使用 != null 而非 if(newVal),确保空字符串 "" 也能触发清空(避免重新开赛时旧文案残留)
|
||||||
|
watch(() => store.game.tips, (newVal) => {
|
||||||
|
if (newVal != null) {
|
||||||
|
tips.value = newVal;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("update-tips", onUpdateTips);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
uni.$off("update-tips", onUpdateTips);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<text>{{ (tips || "").replace(/你/g, "").replace(/重回/g, "") }}</text>
|
||||||
|
<text v-if="totalShot > 0"> ({{ currentShot }}/{{ totalShot }}) </text>
|
||||||
|
<button v-if="!!tips" hover-class="none" @click="updateSound">
|
||||||
|
<image :src="`../../../static/sound${sound ? '' : '-off'}-yellow.png`" mode="widthFix" />
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 50vw;
|
||||||
|
color: #fed847;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container>button:last-child {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container>button:last-child>image {
|
||||||
|
width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
src/pages/team-battle/components/IconButton.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
onClick: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<view class="container" @click="onClick">
|
||||||
|
<image :src="src" mode="widthFix" :style="{ width: width + 'px' }" />
|
||||||
|
<text>{{ name }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.container > text {
|
||||||
|
color: #666666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
src/pages/team-battle/components/PointSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
onChange: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = ref(true);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
mode.value = !mode.value;
|
||||||
|
props.onChange(mode.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="point-switcher"
|
||||||
|
:style="{ borderColor: mode ? '#D8D8D8' : '#53EF56' }"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
@click="onClick"
|
||||||
|
:style="{ transform: 'translateX(' + (mode ? '-58' : '4') + 'rpx)' }"
|
||||||
|
>
|
||||||
|
<text>放大</text>
|
||||||
|
<view :style="{ background: mode ? '#D8D8D8' : '#53EF56' }"></view>
|
||||||
|
<text>真实</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-switcher {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
border: 2rpx solid;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.point-switcher > view {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 40rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20rpx;
|
||||||
|
word-break: keep-all;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
transform: translateX(-58rpx);
|
||||||
|
}
|
||||||
|
.point-switcher > view > text:first-child {
|
||||||
|
color: #53ef56;
|
||||||
|
}
|
||||||
|
.point-switcher > view > view {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 10rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.point-switcher > view > text:last-child {
|
||||||
|
color: #d8d8d8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
src/pages/team-battle/components/RoundEndTip.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
const props = defineProps({
|
||||||
|
isFinal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
round: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
bluePoint: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
redPoint: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
roundData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
onAutoClose: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const count = ref(props.isFinal ? 10 : 3);
|
||||||
|
const tiemr = ref(null);
|
||||||
|
function startCount() {
|
||||||
|
if (tiemr.value) clearInterval(tiemr.value);
|
||||||
|
tiemr.value = setInterval(() => {
|
||||||
|
if (count.value === 0) {
|
||||||
|
clearInterval(tiemr.value);
|
||||||
|
props.onAutoClose();
|
||||||
|
} else count.value -= 1;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
startCount();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (tiemr.value) clearInterval(tiemr.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="round-end-tip">
|
||||||
|
<text>第{{ round }}轮射箭结束</text>
|
||||||
|
<block v-if="!isFinal">
|
||||||
|
<view class="point-view1" v-if="bluePoint !== 0 || redPoint !== 0">
|
||||||
|
<text>本轮蓝队</text>
|
||||||
|
<text>{{
|
||||||
|
(roundData.shoots[1] || []).reduce(
|
||||||
|
(last, next) => last + next.ring,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}}</text>
|
||||||
|
<text>环,红队</text>
|
||||||
|
<text>{{
|
||||||
|
(roundData.shoots[2] || []).reduce(
|
||||||
|
(last, next) => last + next.ring,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}}</text>
|
||||||
|
<text>环</text>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="bluePoint === 0 && redPoint === 0"
|
||||||
|
:style="{ marginTop: '20px' }"
|
||||||
|
>
|
||||||
|
连续3个来回双方均无人射箭,比赛取消。
|
||||||
|
</text>
|
||||||
|
<text v-if="bluePoint !== 0 && bluePoint === redPoint">
|
||||||
|
红队、蓝队各得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||||
|
redPoint
|
||||||
|
}}</text
|
||||||
|
>分
|
||||||
|
</text>
|
||||||
|
<text v-if="bluePoint > redPoint">
|
||||||
|
蓝队获胜,得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||||
|
bluePoint
|
||||||
|
}}</text
|
||||||
|
>分
|
||||||
|
</text>
|
||||||
|
<text v-if="bluePoint < redPoint">
|
||||||
|
红队获胜,得<text :style="{ color: '#fed847', margin: '0 5px' }">{{
|
||||||
|
redPoint
|
||||||
|
}}</text
|
||||||
|
>分
|
||||||
|
</text>
|
||||||
|
</block>
|
||||||
|
<block v-if="isFinal">
|
||||||
|
<view class="point-view2">
|
||||||
|
<text>蓝队</text>
|
||||||
|
<text>{{ bluePoint }}</text>
|
||||||
|
<text>分,红队</text>
|
||||||
|
<text>{{ redPoint }}</text>
|
||||||
|
<text>分</text>
|
||||||
|
</view>
|
||||||
|
<text>同分僵局!最后一箭定江山</text>
|
||||||
|
<view class="final-shoot">
|
||||||
|
<text>{{ count }}</text>
|
||||||
|
<text>秒后蓝红双方</text>
|
||||||
|
<text>决金箭</text>
|
||||||
|
<text>一箭决胜负</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.round-end-tip {
|
||||||
|
width: 100%;
|
||||||
|
color: #fff9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
.round-end-tip > text:first-child {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.point-view1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.point-view1 > text:nth-child(2),
|
||||||
|
.point-view1 > text:nth-child(4) {
|
||||||
|
color: #fed847;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
.point-view2 {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.point-view2 > text:nth-child(2),
|
||||||
|
.point-view2 > text:nth-child(4) {
|
||||||
|
color: #fed847;
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.final-shoot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 28rpx;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
.final-shoot > text:nth-child(1) {
|
||||||
|
width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.final-shoot > text:nth-child(1),
|
||||||
|
.final-shoot > text:nth-child(3) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #fed847;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
src/pages/team-battle/components/SButton.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { debounce } from "@/util";
|
||||||
|
const props = defineProps({
|
||||||
|
width: {
|
||||||
|
type: String,
|
||||||
|
default: "calc(100vw - 20px)",
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
onClick: {
|
||||||
|
type: Function,
|
||||||
|
default: async () => {},
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#fed847",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "#000",
|
||||||
|
},
|
||||||
|
disabledColor:{
|
||||||
|
type: String,
|
||||||
|
default: "#757575",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const timer = ref(null);
|
||||||
|
|
||||||
|
const onBtnClick = debounce(async () => {
|
||||||
|
if (props.disabled || loading.value) return;
|
||||||
|
|
||||||
|
let loadingTimer = null;
|
||||||
|
loadingTimer = setTimeout(() => {
|
||||||
|
loading.value = true;
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.onClick();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="sbtn"
|
||||||
|
hover-class="none"
|
||||||
|
:style="{
|
||||||
|
width: width,
|
||||||
|
borderRadius: rounded + 'px',
|
||||||
|
backgroundColor: disabled ? disabledColor : backgroundColor,
|
||||||
|
color,
|
||||||
|
}"
|
||||||
|
open-type="getUserInfo"
|
||||||
|
@click="onBtnClick"
|
||||||
|
>
|
||||||
|
<block v-if="!loading">
|
||||||
|
<slot />
|
||||||
|
</block>
|
||||||
|
<block v-else>
|
||||||
|
<image src="../../../static/btn-loading.png" mode="widthFix" class="loading" />
|
||||||
|
</block>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sbtn {
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: initial;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
background-blend-mode: darken;
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
src/pages/team-battle/components/SModal.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: "650rpx",
|
||||||
|
},
|
||||||
|
onClose: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
noBg: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const showContainer = ref(false);
|
||||||
|
const showContent = ref(false);
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
showContainer.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showContent.value = true;
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
showContent.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
showContainer.value = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="container"
|
||||||
|
v-if="showContainer"
|
||||||
|
:style="{ opacity: show ? 1 : 0 }"
|
||||||
|
@click="onClose"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="modal-content"
|
||||||
|
:style="{
|
||||||
|
transform: `translateY(${showContent ? '0%' : '100%'})`,
|
||||||
|
height,
|
||||||
|
}"
|
||||||
|
@click.stop=""
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="!noBg"
|
||||||
|
src="https://static.shelingxingqiu.com/attachment/2025-12-04/dep11770wzxg6o2alo.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<view class="close-btn" @click="onClose" v-if="!noBg">
|
||||||
|
<image src="../../../static/close-yellow.png" mode="widthFix" />
|
||||||
|
</view>
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #00000099;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 100%;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
background-color: #372E1D;
|
||||||
|
}
|
||||||
|
.modal-content > image:first-child {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.close-btn > image {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
src/pages/team-battle/components/ScreenHint.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
import IconButton from "./IconButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
onClose: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "normal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const getContentHeight = () => {
|
||||||
|
if (props.mode === "tall") return "50vw";
|
||||||
|
if (props.mode === "square") return "74vw";
|
||||||
|
return "36vw";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||||
|
<view class="scale-in" :style="{ height: getContentHeight() }">
|
||||||
|
<image
|
||||||
|
v-if="mode === 'normal'"
|
||||||
|
src="../../../static/screen-hint-bg.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="mode === 'tall'"
|
||||||
|
src="../../../static/coach-comment.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="mode === 'square'"
|
||||||
|
src="../../../static/prompt-bg-square.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="mode === 'small'"
|
||||||
|
src="../../../static/finish-frame.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</view>
|
||||||
|
<IconButton
|
||||||
|
v-if="!!onClose"
|
||||||
|
src="../../../static/close-gold-outline.png"
|
||||||
|
:width="30"
|
||||||
|
:onClick="onClose"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.container > view:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
width: 70vw;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.container > view:first-child > image {
|
||||||
|
position: absolute;
|
||||||
|
width: 80vw;
|
||||||
|
left: -7%;
|
||||||
|
bottom: -18vw;
|
||||||
|
z-index: -1;
|
||||||
|
transform: translateY(-75px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
src/pages/team-battle/components/ShootProgress2.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup>
|
||||||
|
import {ref, watch, onMounted, onBeforeUnmount} from "vue";
|
||||||
|
import {RoundGoldImages} from "@/constants";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tips: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
currentRound: {
|
||||||
|
type: String,
|
||||||
|
default: "round1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const barColor = ref("");
|
||||||
|
const remain = ref(12);
|
||||||
|
const timer = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const transitionStyle = ref("all 1s linear");
|
||||||
|
const currentTeam = ref(null);
|
||||||
|
const MIN_TICK_MS = 1;
|
||||||
|
const ZERO_EVENT = "team-battle-progress-zero";
|
||||||
|
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
|
||||||
|
|
||||||
|
const clearCountdownTimer = () => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearInterval(timer.value);
|
||||||
|
timer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTransitionByTick = (tickMs) => {
|
||||||
|
transitionStyle.value = `all ${Math.max(MIN_TICK_MS, Math.round(tickMs || 1000))}ms linear`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitProgressZero = () => {
|
||||||
|
uni.$emit(ZERO_EVENT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitCountdownReady = () => {
|
||||||
|
uni.$emit(COUNTDOWN_READY_EVENT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRemain = (value) => {
|
||||||
|
if (value.stop) {
|
||||||
|
clearCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// zeroThenReset:ToSomeoneShoot 到达时,若进度条仍在倒计时则先瞬间清零(约 150ms 停留)再显示下一玩家满值
|
||||||
|
// 若进度条已到 0(loading 状态),直接切换满值
|
||||||
|
if (value.zeroThenReset) {
|
||||||
|
clearCountdownTimer();
|
||||||
|
const wasNonZero = remain.value > 0;
|
||||||
|
// 更新下一玩家颜色和方向(在清零和满值时均生效)
|
||||||
|
currentTeam.value = value.team;
|
||||||
|
if (value.team === 'red') barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||||
|
if (value.team === 'blue') barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||||
|
transitionStyle.value = "none";
|
||||||
|
if (wasNonZero) {
|
||||||
|
// 瞬间清零,停留约 150ms 后切换为满值
|
||||||
|
remain.value = 0;
|
||||||
|
loading.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
remain.value = value.value;
|
||||||
|
loading.value = false;
|
||||||
|
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
// 已在底部,直接切换满值
|
||||||
|
remain.value = value.value;
|
||||||
|
loading.value = false;
|
||||||
|
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
currentTeam.value = value.team
|
||||||
|
if (value.team === 'red')
|
||||||
|
barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||||
|
if (value.team === 'blue')
|
||||||
|
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||||
|
const nextVal = Math.max(0, Math.round(Number(value.value) || 0));
|
||||||
|
const nextTickMs = Math.max(MIN_TICK_MS, Math.round(Number(value.tickMs) || 1000));
|
||||||
|
if (value.reset) {
|
||||||
|
// 重置前先清除旧计时器,防止超时未射箭时旧 interval 残留,导致进度条震荡
|
||||||
|
clearCountdownTimer();
|
||||||
|
// 重置时瞬间跳满格,禁用 CSS 过渡避免从旧值「涨到满」的动画
|
||||||
|
transitionStyle.value = "none";
|
||||||
|
remain.value = nextVal;
|
||||||
|
loading.value = nextVal <= 0;
|
||||||
|
setTimeout(() => {
|
||||||
|
setTransitionByTick(nextTickMs);
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果剩余时间增加(如轮次切换重置),瞬间变化无动画;否则保持动画
|
||||||
|
if (nextVal >= remain.value) {
|
||||||
|
transitionStyle.value = "none";
|
||||||
|
remain.value = nextVal;
|
||||||
|
setTimeout(() => {
|
||||||
|
setTransitionByTick(nextTickMs);
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
remain.value = nextVal;
|
||||||
|
setTransitionByTick(nextTickMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动前先清除旧计时器,防止多次 {stop:false} 事件叠加多个 interval
|
||||||
|
clearCountdownTimer();
|
||||||
|
if (nextVal <= 0) {
|
||||||
|
loading.value = true;
|
||||||
|
emitProgressZero();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.value = setInterval(() => {
|
||||||
|
if (remain.value > 0) remain.value--;
|
||||||
|
if (remain.value <= 0) {
|
||||||
|
remain.value = 0;
|
||||||
|
loading.value = true;
|
||||||
|
clearCountdownTimer();
|
||||||
|
emitProgressZero();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}, nextTickMs);
|
||||||
|
emitCountdownReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.tips,
|
||||||
|
(newVal) => {
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("update-remain", updateRemain);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
uni.$off("update-remain", updateRemain);
|
||||||
|
clearCountdownTimer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<image :src="RoundGoldImages[props.currentRound]" mode="widthFix"/>
|
||||||
|
<view
|
||||||
|
:style="{
|
||||||
|
justifyContent: currentTeam==='red' ? 'flex-end' : 'flex-start',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
:style="{
|
||||||
|
width: `${(remain / total) * 100}%`,
|
||||||
|
background: barColor,
|
||||||
|
right: currentTeam==='red' ? 0 : 'unset',
|
||||||
|
transition: transitionStyle,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<text v-if="!loading">剩余{{ remain }}秒</text>
|
||||||
|
<text v-else>···</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 50vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > image {
|
||||||
|
width: 380rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
transform: translateY(18rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > view:last-child {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #444444;
|
||||||
|
border-radius: 20px;
|
||||||
|
height: 24rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > view:last-child > view {
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > view:last-child > text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
src/pages/team-battle/components/TeamAvatars.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
const props = defineProps({
|
||||||
|
isRed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentShooterId: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const players = ref({});
|
||||||
|
const currentTeam = ref(false);
|
||||||
|
const firstName = ref("");
|
||||||
|
|
||||||
|
// 抽出判断:当前队伍且该玩家排序为 0(队伍首位)
|
||||||
|
const isFirst = (id) =>
|
||||||
|
currentTeam.value && ((players.value[id] || {}).sort || 0) === 0;
|
||||||
|
|
||||||
|
const getPos = (id) => {
|
||||||
|
const sort = (players.value[id] || {}).sort || 0;
|
||||||
|
if (currentTeam.value) {
|
||||||
|
return 30 * (sort + Math.ceil(sort / 2));
|
||||||
|
}
|
||||||
|
return sort * 40;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPlayers = () => {
|
||||||
|
const nextPlayers = {};
|
||||||
|
const shooterId = props.currentShooterId;
|
||||||
|
const shooterIndex = props.team.findIndex(
|
||||||
|
(p) => String(p?.id) === String(shooterId)
|
||||||
|
);
|
||||||
|
const nextTeam = [...props.team];
|
||||||
|
|
||||||
|
currentTeam.value = !!shooterId && shooterIndex >= 0;
|
||||||
|
firstName.value = "";
|
||||||
|
|
||||||
|
if (currentTeam.value) {
|
||||||
|
const target = nextTeam.splice(shooterIndex, 1)[0];
|
||||||
|
if (target) {
|
||||||
|
nextTeam.unshift(target);
|
||||||
|
firstName.value = target.name || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTeam.forEach((p, index) => {
|
||||||
|
if (p?.id) nextPlayers[p.id] = { sort: index, ...p };
|
||||||
|
});
|
||||||
|
players.value = nextPlayers;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.team, () => props.currentShooterId],
|
||||||
|
syncPlayers,
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<image
|
||||||
|
:src="isRed ? '../../../static/flag-red.png' : '../../../static/flag-blue.png'"
|
||||||
|
class="flag"
|
||||||
|
:style="{
|
||||||
|
[isRed ? 'left' : 'right']: '10rpx',
|
||||||
|
top: currentTeam ? '-36rpx' : '-24rpx',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in team"
|
||||||
|
:key="item.id || index"
|
||||||
|
class="player"
|
||||||
|
:style="{
|
||||||
|
width: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||||
|
height: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||||
|
zIndex: team.length - ((players[item.id] || {}).sort || 0),
|
||||||
|
border: isFirst(item.id) ? '3.5rpx solid' : '2rpx solid',
|
||||||
|
borderColor: isRed ? '#ff6060' : '#5fadff',
|
||||||
|
top: isFirst(item.id) ? '0rpx' : '12rpx',
|
||||||
|
[isRed ? 'left' : 'right']: getPos(item.id) + 'rpx',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<image :src="item.avatar || '../../../static/user-icon.png'" mode="widthFix" />
|
||||||
|
<text
|
||||||
|
v-if="isFirst(item.id)"
|
||||||
|
:style="{ backgroundColor: isRed ? '#ff6060' : '#5fadff' }"
|
||||||
|
>{{ isRed ? "红队" : "蓝队" }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="currentTeam"
|
||||||
|
class="truncate"
|
||||||
|
:style="{
|
||||||
|
color: isRed ? '#ff6060' : '#5fadff',
|
||||||
|
[isRed ? 'left' : 'right']: '-4rpx',
|
||||||
|
}"
|
||||||
|
>{{ firstName }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 20vw;
|
||||||
|
height: 10rpx;
|
||||||
|
margin: 0 20rpx;
|
||||||
|
}
|
||||||
|
.container > text {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
width: 80rpx;
|
||||||
|
bottom: -100rpx;
|
||||||
|
}
|
||||||
|
.player {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.player > image {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.player > text {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 15rpx;
|
||||||
|
text-align: center;
|
||||||
|
width: 76rpx;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.flag {
|
||||||
|
position: absolute;
|
||||||
|
width: 45rpx;
|
||||||
|
height: 45rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
src/pages/team-battle/components/TestDistance.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import Guide from "./Guide.vue";
|
||||||
|
import BowPower from "./BowPower.vue";
|
||||||
|
import Avatar from "./Avatar.vue";
|
||||||
|
import audioManager from "@/audioManager";
|
||||||
|
import { simulShootAPI } from "@/apis";
|
||||||
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
const store = useStore();
|
||||||
|
const { user, device } = storeToRefs(store);
|
||||||
|
const props = defineProps({
|
||||||
|
guide: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
isBattle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
default: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const arrow = ref({});
|
||||||
|
const distance = ref(0);
|
||||||
|
const showsimul = ref(false);
|
||||||
|
const count = ref(props.count);
|
||||||
|
const timer = ref(null);
|
||||||
|
|
||||||
|
const updateTimer = (value) => {
|
||||||
|
count.value = Math.round(value);
|
||||||
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
audioManager.play("请射箭测试距离");
|
||||||
|
if (props.isBattle) {
|
||||||
|
timer.value = setInterval(() => {
|
||||||
|
count.value -= 1;
|
||||||
|
if (count.value < 0) clearInterval(timer.value);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
uni.$on("update-timer", updateTimer);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer.value) clearInterval(timer.value);
|
||||||
|
uni.$off("update-timer", updateTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onReceiveMessage(msg) {
|
||||||
|
if (Array.isArray(msg)) return;
|
||||||
|
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||||
|
distance.value = Number((msg.shootData.distance / 100).toFixed(2));
|
||||||
|
if (distance.value >= 5) audioManager.play("距离合格");
|
||||||
|
else audioManager.play("距离不足");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulShoot = async () => {
|
||||||
|
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
const accountInfo = uni.getAccountInfoSync();
|
||||||
|
const envVersion = accountInfo.miniProgram.envVersion;
|
||||||
|
if (envVersion !== "release") showsimul.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<Guide v-show="guide">
|
||||||
|
<view class="guide-tips">
|
||||||
|
<text>请确保站距达到5米</text>
|
||||||
|
<text>低于5米的射箭无效</text>
|
||||||
|
</view>
|
||||||
|
</Guide>
|
||||||
|
<view class="test-area">
|
||||||
|
<image
|
||||||
|
class="text-bg"
|
||||||
|
src="https://static.shelingxingqiu.com/attachment/2025-07-05/db3skuq1n9rj4fmld4.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="simul"
|
||||||
|
@click="simulShoot"
|
||||||
|
hover-class="none"
|
||||||
|
v-if="showsimul"
|
||||||
|
>
|
||||||
|
模拟射箭
|
||||||
|
</button>
|
||||||
|
<view class="warnning-text">
|
||||||
|
<block v-if="distance > 0">
|
||||||
|
<text>当前距离{{ distance }}米</text>
|
||||||
|
<text v-if="distance >= 5">已达到距离要求</text>
|
||||||
|
<text v-else>请调整站位</text>
|
||||||
|
</block>
|
||||||
|
<block v-else>
|
||||||
|
<text>请射箭,测试站距</text>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view class="user-row">
|
||||||
|
<Avatar :src="user.avatar" :size="35" />
|
||||||
|
<BowPower />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="isBattle" class="ready-timer">
|
||||||
|
<image src="../../../static/test-tip.png" mode="widthFix" />
|
||||||
|
<view v-if="count >= 0">
|
||||||
|
<text>具体正式比赛还有</text>
|
||||||
|
<text>{{ count }}</text>
|
||||||
|
<text>秒</text>
|
||||||
|
</view>
|
||||||
|
<view v-else> 进入中... </view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
.ready-timer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateY(-10vw);
|
||||||
|
}
|
||||||
|
.ready-timer > image:first-child {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
.ready-timer > view {
|
||||||
|
width: 80%;
|
||||||
|
height: 45px;
|
||||||
|
background-color: #545454;
|
||||||
|
border-radius: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateY(-8vw);
|
||||||
|
color: #bebebe;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.ready-timer > view > text:nth-child(2) {
|
||||||
|
color: #fed847;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.test-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
height: 112vw;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.test-area > view:last-child {
|
||||||
|
padding: 15px;
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
}
|
||||||
|
.text-bg {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: -14.4%;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.warnning-text {
|
||||||
|
color: #fed847;
|
||||||
|
font-size: 27px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
height: 40%;
|
||||||
|
}
|
||||||
|
.warnning-text > text {
|
||||||
|
width: 70vw;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.simul {
|
||||||
|
position: absolute;
|
||||||
|
color: #fff;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1277
src/pages/team-battle/index.vue
Normal file
BIN
src/static/ota/check-char.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src/static/ota/close-char.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/static/ota/new-ver.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src/static/ota/ota-bg.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/static/ota/ota-mascot.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/static/ota/ota-ver.png
Normal file
|
After Width: | Height: | Size: 237 B |
BIN
src/static/ota/target-char.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
src/static/ota/update-fail.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src/static/ota/update-ok.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src/static/ota/update_progress.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/static/ota/wifi1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src/static/ota/wifi2.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/static/sicon/arrow-left.png
Normal file
|
After Width: | Height: | Size: 277 B |
BIN
src/static/sicon/cancel.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/static/sicon/check.png
Normal file
|
After Width: | Height: | Size: 390 B |
BIN
src/static/sicon/close.png
Normal file
|
After Width: | Height: | Size: 603 B |
BIN
src/static/sicon/eye-off.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
src/static/sicon/eye-on.png
Normal file
|
After Width: | Height: | Size: 457 B |
BIN
src/static/sicon/pwd.png
Normal file
|
After Width: | Height: | Size: 407 B |
BIN
src/static/sicon/refresh.png
Normal file
|
After Width: | Height: | Size: 494 B |
BIN
src/static/sicon/target_icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/static/sicon/tick.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/static/sicon/wifi.png
Normal file
|
After Width: | Height: | Size: 548 B |
39
src/store.js
@@ -1,5 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
const PERSISTED_STORE_KEY = "store";
|
||||||
|
|
||||||
const defaultUser = {
|
const defaultUser = {
|
||||||
id: "",
|
id: "",
|
||||||
nickName: "",
|
nickName: "",
|
||||||
@@ -8,6 +10,22 @@ const defaultUser = {
|
|||||||
lvlName: "",
|
lvlName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDefaultUser = () => ({ ...defaultUser });
|
||||||
|
|
||||||
|
const getDefaultDevice = () => ({
|
||||||
|
deviceId: "",
|
||||||
|
deviceName: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDefaultGame = () => ({
|
||||||
|
roomID: "",
|
||||||
|
inBattle: false,
|
||||||
|
roomNumber: "",
|
||||||
|
currentShot: 0,
|
||||||
|
totalShot: 0,
|
||||||
|
tips: "",
|
||||||
|
});
|
||||||
|
|
||||||
const getLvlName = (rankLvl, rankList = []) => {
|
const getLvlName = (rankLvl, rankList = []) => {
|
||||||
if (!rankList) return;
|
if (!rankList) return;
|
||||||
let lvlName = "";
|
let lvlName = "";
|
||||||
@@ -65,11 +83,8 @@ const getLvlImageByScore = (score, rankList = []) => {
|
|||||||
export default defineStore("store", {
|
export default defineStore("store", {
|
||||||
// 状态
|
// 状态
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: defaultUser,
|
user: getDefaultUser(),
|
||||||
device: {
|
device: getDefaultDevice(),
|
||||||
deviceId: "",
|
|
||||||
deviceName: "",
|
|
||||||
},
|
|
||||||
config: {},
|
config: {},
|
||||||
rankData: {
|
rankData: {
|
||||||
rank: [],
|
rank: [],
|
||||||
@@ -111,7 +126,7 @@ export default defineStore("store", {
|
|||||||
this.online = online;
|
this.online = online;
|
||||||
},
|
},
|
||||||
async updateUser(user = {}) {
|
async updateUser(user = {}) {
|
||||||
this.user = { ...defaultUser, ...user };
|
this.user = { ...getDefaultUser(), ...user };
|
||||||
this.user.lvlName = getLvlNameByScore(this.user.scores, this.config.randInfos)
|
this.user.lvlName = getLvlNameByScore(this.user.scores, this.config.randInfos)
|
||||||
this.user.lvlImage = getLvlImageByScore(
|
this.user.lvlImage = getLvlImageByScore(
|
||||||
this.user.scores,
|
this.user.scores,
|
||||||
@@ -152,6 +167,18 @@ export default defineStore("store", {
|
|||||||
updateRoomNumber(number) {
|
updateRoomNumber(number) {
|
||||||
this.game.roomNumber = number;
|
this.game.roomNumber = number;
|
||||||
},
|
},
|
||||||
|
clearSessionState() {
|
||||||
|
this.$patch({
|
||||||
|
user: getDefaultUser(),
|
||||||
|
device: getDefaultDevice(),
|
||||||
|
online: false,
|
||||||
|
game: getDefaultGame(),
|
||||||
|
});
|
||||||
|
uni.removeStorageSync(PERSISTED_STORE_KEY);
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.removeStorageSync(PERSISTED_STORE_KEY);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据持久化(via pinia-plugin-persistedstate)
|
// 数据持久化(via pinia-plugin-persistedstate)
|
||||||
|
|||||||
158
src/websocket.js
@@ -1,12 +1,22 @@
|
|||||||
import { MESSAGETYPES, getMessageTypeName } from "@/constants";
|
import { MESSAGETYPES, getMessageTypeName } from "@/constants";
|
||||||
|
import { getUserGameState } from "@/apis";
|
||||||
|
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let heartbeatInterval = null;
|
let heartbeatInterval = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
|
let manualClose = false;
|
||||||
|
let checkingSession = false;
|
||||||
|
let kickedOut = false;
|
||||||
|
let isConnecting = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* 建立 WebSocket 连接
|
|
||||||
*/
|
|
||||||
function createWebSocket(token, onMessage) {
|
function createWebSocket(token, onMessage) {
|
||||||
|
if (!token) return;
|
||||||
|
if (kickedOut) kickedOut = false;
|
||||||
|
if (socket || isConnecting) return;
|
||||||
|
|
||||||
|
manualClose = false;
|
||||||
|
isConnecting = true;
|
||||||
|
|
||||||
let url = "wss://api.shelingxingqiu.com/socket";
|
let url = "wss://api.shelingxingqiu.com/socket";
|
||||||
try {
|
try {
|
||||||
const accountInfo = uni.getAccountInfoSync();
|
const accountInfo = uni.getAccountInfoSync();
|
||||||
@@ -20,42 +30,65 @@ function createWebSocket(token, onMessage) {
|
|||||||
case "trial": // 体验版
|
case "trial": // 体验版
|
||||||
url = "wss://apitest.shelingxingqiu.com/socket";
|
url = "wss://apitest.shelingxingqiu.com/socket";
|
||||||
break;
|
break;
|
||||||
case "release": // 正式版
|
case "trial":
|
||||||
|
url = "wss://apitest.shelingxingqiu.com/socket";
|
||||||
|
break;
|
||||||
|
case "release":
|
||||||
url = "wss://api.shelingxingqiu.com/socket";
|
url = "wss://api.shelingxingqiu.com/socket";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// 保持默认值
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
console.error("获取 WebSocket 环境信息失败,使用默认正式环境", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
url += `?authorization=${token}`;
|
url += `?authorization=${token}`;
|
||||||
socket = uni.connectSocket({
|
const socketTask = uni.connectSocket({
|
||||||
url,
|
url,
|
||||||
success: () => {
|
success: () => {
|
||||||
console.log("websocket 连接成功");
|
console.log("WebSocket 已发起连接");
|
||||||
// 启动心跳
|
|
||||||
startHeartbeat(onMessage);
|
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: (err) => {
|
||||||
|
if (socket !== socketTask) return;
|
||||||
|
console.error("WebSocket 连接失败", err);
|
||||||
|
socket = null;
|
||||||
|
isConnecting = false;
|
||||||
reconnect(onMessage);
|
reconnect(onMessage);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 接收消息
|
socket = socketTask;
|
||||||
uni.onSocketMessage((res) => {
|
|
||||||
|
socketTask.onOpen(() => {
|
||||||
|
if (socket !== socketTask) return;
|
||||||
|
console.log("WebSocket 连接成功");
|
||||||
|
isConnecting = false;
|
||||||
|
startHeartbeat(onMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketTask.onMessage((res) => {
|
||||||
|
if (socket !== socketTask) return;
|
||||||
|
|
||||||
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("收到消息:", getMessageTypeName(data.type), data.data);
|
console.log(
|
||||||
|
"收到 WebSocket 消息",
|
||||||
|
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) {
|
||||||
console.log("收到消息:", getMessageTypeName(msg.constructor), msg);
|
console.log(
|
||||||
|
"收到 WebSocket 更新",
|
||||||
|
getMessageTypeName(msg.constructor),
|
||||||
|
msg
|
||||||
|
);
|
||||||
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) {
|
||||||
@@ -68,84 +101,109 @@ function createWebSocket(token, onMessage) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 错误处理
|
socketTask.onError((err) => {
|
||||||
uni.onSocketError((err) => {
|
if (socket !== socketTask) return;
|
||||||
console.error("WebSocket 错误", err);
|
console.error("WebSocket 错误", err);
|
||||||
reconnect(onMessage);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
uni.onSocketClose((result) => {
|
socketTask.onClose(async (result) => {
|
||||||
|
if (socket !== socketTask) return;
|
||||||
console.log("WebSocket 已关闭", result);
|
console.log("WebSocket 已关闭", result);
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
reconnect(onMessage);
|
socket = null;
|
||||||
|
isConnecting = false;
|
||||||
|
|
||||||
|
if (manualClose || kickedOut) return;
|
||||||
|
await handleUnexpectedClose(onMessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function handleUnexpectedClose(onMessage) {
|
||||||
* 重连机制
|
if (checkingSession || manualClose || kickedOut) return;
|
||||||
*/
|
|
||||||
function reconnect(onMessage) {
|
|
||||||
reconnectTimer && clearTimeout(reconnectTimer);
|
|
||||||
closeWebSocket(); // 确保关闭旧连接
|
|
||||||
|
|
||||||
const token = uni.getStorageSync(
|
const token = uni.getStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
|
checkingSession = true;
|
||||||
|
try {
|
||||||
|
await getUserGameState();
|
||||||
|
if (!manualClose && !kickedOut) reconnect(onMessage);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.type === "AUTH_INVALID") {
|
||||||
|
kickedOut = true;
|
||||||
|
manualClose = true;
|
||||||
|
reconnectTimer && clearTimeout(reconnectTimer);
|
||||||
|
uni.$emit("session-kicked-out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!manualClose && !kickedOut) reconnect(onMessage);
|
||||||
|
} finally {
|
||||||
|
checkingSession = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnect(onMessage) {
|
||||||
|
reconnectTimer && clearTimeout(reconnectTimer);
|
||||||
|
|
||||||
|
const token = uni.getStorageSync(
|
||||||
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
|
);
|
||||||
|
if (!token || manualClose || kickedOut || socket || isConnecting) return;
|
||||||
|
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
console.log("reconnecting...");
|
if (manualClose || kickedOut || socket || isConnecting) return;
|
||||||
|
console.log("WebSocket 正在重连...");
|
||||||
createWebSocket(token, onMessage);
|
createWebSocket(token, onMessage);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWebSocket() {
|
function closeWebSocket(isManual = true) {
|
||||||
|
manualClose = isManual;
|
||||||
|
reconnectTimer && clearTimeout(reconnectTimer);
|
||||||
|
stopHeartbeat();
|
||||||
|
isConnecting = false;
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
reconnectTimer && clearTimeout(reconnectTimer);
|
const currentSocket = socket;
|
||||||
stopHeartbeat();
|
socket = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.close();
|
currentSocket.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("关闭WebSocket连接失败", err);
|
console.error("关闭 WebSocket 失败", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket = null; // 清除socket引用
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendHeartbeat(onMessage) {
|
function sendHeartbeat(onMessage) {
|
||||||
uni.sendSocketMessage({
|
if (!socket) return;
|
||||||
|
|
||||||
|
const currentSocket = socket;
|
||||||
|
currentSocket.send({
|
||||||
data: JSON.stringify({ event: "ping", data: {} }),
|
data: JSON.stringify({ event: "ping", data: {} }),
|
||||||
success: () => {
|
success: () => {},
|
||||||
// console.log("发送心跳成功");
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error("发送心跳失败", err);
|
if (socket !== currentSocket) return;
|
||||||
|
console.error("心跳发送失败", err);
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
closeWebSocket(); // 关闭失效的连接
|
closeWebSocket(false);
|
||||||
reconnect(onMessage); // 触发重连
|
reconnect(onMessage);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动心跳
|
|
||||||
*/
|
|
||||||
function startHeartbeat(onMessage) {
|
function startHeartbeat(onMessage) {
|
||||||
stopHeartbeat(); // 防止重复启动
|
stopHeartbeat();
|
||||||
|
|
||||||
heartbeatInterval = setInterval(() => {
|
heartbeatInterval = setInterval(() => {
|
||||||
if (socket && socket.readyState === 1) {
|
if (socket) {
|
||||||
// 检查连接状态
|
|
||||||
sendHeartbeat(onMessage);
|
sendHeartbeat(onMessage);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止心跳
|
|
||||||
*/
|
|
||||||
function stopHeartbeat() {
|
function stopHeartbeat() {
|
||||||
if (heartbeatInterval) {
|
if (heartbeatInterval) {
|
||||||
clearInterval(heartbeatInterval);
|
clearInterval(heartbeatInterval);
|
||||||
|
|||||||