Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a495de60 | ||
|
|
ef13ff6a2b | ||
|
|
8b7a94b8c4 | ||
|
|
40b94dbad8 | ||
|
|
422a93dbe2 | ||
|
|
5c74edf001 | ||
|
|
937308e51d | ||
|
|
165f1a40bd | ||
|
|
35c49bbb10 | ||
|
|
ad0fb0b337 | ||
|
|
02290615e8 | ||
|
|
df628b48c6 | ||
|
|
ca6bba4dff | ||
|
|
c435372d33 | ||
|
|
4c32bbfb13 | ||
|
|
54e5427a77 | ||
|
|
9b47b313a7 | ||
|
|
350c6373ab | ||
|
|
048b0e9fa9 | ||
|
|
39d34d5bff | ||
|
|
37f4f0c019 | ||
|
|
e9cd9c8c9f | ||
|
|
73a41ab897 | ||
|
|
32c37e283f | ||
|
|
209103742d | ||
|
|
27d11a8129 | ||
|
|
c9ea368a19 | ||
|
|
b5e7e72104 | ||
|
|
7a00c1bb1f |
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
.history
|
||||
.github
|
||||
.claude
|
||||
openspec
|
||||
CLAUDE.md
|
||||
docs
|
||||
|
||||
@@ -23,7 +23,10 @@ try {
|
||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
||||
}
|
||||
|
||||
function request(method, url, data = {}) {
|
||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||
|
||||
// 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
@@ -31,16 +34,21 @@ function request(method, url, data = {}) {
|
||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}${url}`,
|
||||
url: `${baseUrl}${url}`,
|
||||
method,
|
||||
header,
|
||||
data,
|
||||
timeout: 10000,
|
||||
success: (res) => {
|
||||
if (url === "/user/hardwareBox/connectWifi" && res.statusCode === 200 && res.data && Object.keys(res.data).length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (res.data) {
|
||||
const {code, data, message} = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
else if (message) {
|
||||
const error = {code, data, message};
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
console.log('1111111111111111111,token失效')
|
||||
uni.removeStorageSync(
|
||||
@@ -50,6 +58,10 @@ function request(method, url, data = {}) {
|
||||
reject({ type: "AUTH_INVALID", message });
|
||||
return;
|
||||
}
|
||||
if (message.indexOf("已达上限") !== -1) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (message === "ROOM_FULL") {
|
||||
resolve({full: true});
|
||||
return;
|
||||
@@ -97,8 +109,10 @@ function request(method, url, data = {}) {
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
reject("");
|
||||
reject({code, data, message});
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
@@ -167,6 +181,10 @@ export const getAppConfig = () => {
|
||||
return request("GET", "/index/appConfig");
|
||||
};
|
||||
|
||||
export const getDailyCountAPI = () => {
|
||||
return request("GET", "/index/dailyCount", {}, ADDONS_BASE_URL);
|
||||
};
|
||||
|
||||
export const getHomeData = (seasonId) => {
|
||||
return request("GET", `/user/myHome?seasonId=${seasonId}`);
|
||||
};
|
||||
@@ -460,6 +478,26 @@ export const getDeviceBatteryAPI = async () => {
|
||||
return request("GET", "/user/device/battery");
|
||||
};
|
||||
|
||||
// 设备连接指定 WiFi,只下发 WiFi 凭证,不触发 OTA 升级。
|
||||
export const connectDeviceWifiAPI = async (ssid, password) => {
|
||||
return request("POST", "/user/hardwareBox/connectWifi", {ssid, password});
|
||||
};
|
||||
|
||||
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
||||
export const getHardwareBoxVersionAPI = async () => {
|
||||
return request("GET", "/user/hardwareBox/version");
|
||||
};
|
||||
|
||||
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||
export const sendHardwareBoxUpdateAPI = async (data) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
||||
};
|
||||
|
||||
// 根据任务 ID 获取硬件盒子 OTA 更新状态。
|
||||
export const getHardwareBoxTaskStatusAPI = async (taskId) => {
|
||||
return request("GET", `/user/hardwareBox/taskStatus?taskId=${taskId}`);
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
return request("POST", "/user/score/sheet/remark", {id, remark});
|
||||
};
|
||||
|
||||
@@ -456,23 +456,29 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
console.error("generateShareImage 绘制失败:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
// 顶部导入与工具方法
|
||||
async function getCanvas2DContext(canvasId, targetWidth, targetHeight) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query
|
||||
.select(`#${canvasId}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const { node: canvas } = res[0] || {};
|
||||
const canvasInfo = res && res[0];
|
||||
const { node: canvas } = canvasInfo || {};
|
||||
if (!canvas || typeof canvas.getContext !== "function") {
|
||||
reject(new Error(`canvas ${canvasId} not found`));
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
|
||||
|
||||
const w = targetWidth || res[0].width;
|
||||
const h = targetHeight || res[0].height;
|
||||
const w = targetWidth || canvasInfo.width;
|
||||
const h = targetHeight || canvasInfo.height;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
@@ -561,6 +567,7 @@ export const sharePointData = async (canvasId, data) => {
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
console.error("generateShareImage 绘制失败:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -778,6 +785,7 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
// 2D 模式下无需 ctx.draw()
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
|
||||
const OTA_MIN_BATTERY = 50;
|
||||
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
|
||||
const OTA_OFFLINE_TEXT = "请先开启智能弓";
|
||||
|
||||
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)));
|
||||
|
||||
// 点击立即更新前先校验设备在线状态,再校验设备电量。
|
||||
const handleUpdateClick = async () => {
|
||||
try {
|
||||
const deviceStatus = await getDeviceBatteryAPI();
|
||||
if (deviceStatus?.online !== true) {
|
||||
uni.showToast({
|
||||
title: OTA_OFFLINE_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) {
|
||||
uni.showToast({
|
||||
title: OTA_LOW_BATTERY_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
emit("update");
|
||||
return;
|
||||
}
|
||||
emit("update");
|
||||
};
|
||||
</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="aspectFit" 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="handleUpdateClick">
|
||||
<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 || isFailure) && !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;
|
||||
width: 116rpx;
|
||||
height: 44rpx;
|
||||
/* 离标题图 -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>
|
||||
@@ -70,6 +70,11 @@ const arrows = computed(() => {
|
||||
});
|
||||
|
||||
const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
||||
const isMember = computed(() => user.value.vip === true || user.value.sVip === true);
|
||||
const openCoachComment = () => {
|
||||
if (!isMember.value) return;
|
||||
showComment.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,9 +117,10 @@ const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
||||
:onClick="onClickShare"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="isMember"
|
||||
name="教练点评"
|
||||
src="../static/review.png"
|
||||
:onClick="() => (showComment = true)"
|
||||
:onClick="openCoachComment"
|
||||
/>
|
||||
</block>
|
||||
<SButton
|
||||
|
||||
@@ -64,7 +64,11 @@
|
||||
"usingComponents" : true,
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json",
|
||||
"permission" : {},
|
||||
"permission" : {
|
||||
"scope.userLocation": {
|
||||
"desc": "用于扫描附近 WiFi,完成设备 OTA 升级网络连接"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,12 @@
|
||||
},
|
||||
{
|
||||
"path": "pages/mine-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/ota-wifi",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import GuideTwo from "@/components/GuideTwo.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import {
|
||||
getRoomAPI,
|
||||
exitRoomAPI,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getReadyAPI,
|
||||
kickPlayerAPI,
|
||||
} from "@/apis";
|
||||
import { isLimitError } from "@/util";
|
||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -56,6 +58,7 @@ const ready = ref(false);
|
||||
const allReady = ref(false);
|
||||
const timer = ref(null);
|
||||
const goBattle = ref(false);
|
||||
const showLimitModal = ref(false);
|
||||
/** 从结算页返回时为 true,跳过进场靶纸语音 */
|
||||
const skipTargetAudio = ref(false);
|
||||
|
||||
@@ -137,7 +140,26 @@ async function refreshRoomData() {
|
||||
}
|
||||
|
||||
const getReady = async () => {
|
||||
try {
|
||||
await getReadyAPI(roomNumber.value);
|
||||
} catch (error) {
|
||||
if (isLimitError(error)) {
|
||||
showLimitModal.value = true;
|
||||
return;
|
||||
}
|
||||
console.log("room ready error", error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeLimitModal = () => {
|
||||
showLimitModal.value = false;
|
||||
};
|
||||
|
||||
const goVipPage = () => {
|
||||
showLimitModal.value = false;
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
});
|
||||
};
|
||||
|
||||
const refreshMembers = (members = []) => {
|
||||
@@ -456,6 +478,14 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
<!-- 踢出玩家二次确认弹窗(不传 onClose,屏蔽 X 关闭按钮) -->
|
||||
<ScreenHint :show="showKickConfirm">
|
||||
<view class="kick-confirm">
|
||||
|
||||
@@ -48,6 +48,7 @@ const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const laserActive = ref(false);
|
||||
const guideSwiperIndex = ref(0);
|
||||
const sharing = ref(false);
|
||||
|
||||
const guideImages = [
|
||||
"https://static.shelingxingqiu.com/shootmini/static/target.png",
|
||||
@@ -139,8 +140,19 @@ async function onReceiveMessage(msg) {
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
if (sharing.value) return;
|
||||
sharing.value = true;
|
||||
try {
|
||||
await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
sharing.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -8,14 +8,16 @@ import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import CreateRoom from "@/components/CreateRoom.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
|
||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI } from "@/apis";
|
||||
import { debounce, canEenter } from "@/util";
|
||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI, getDailyCountAPI } from "@/apis";
|
||||
import { debounce, canEenter, getLimitCountText, isLimitReached } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device, online, game } = storeToRefs(store);
|
||||
const { user, device, online, game, dailyCount } = storeToRefs(store);
|
||||
const { updateDailyCount } = store;
|
||||
|
||||
const showModal = ref(false);
|
||||
const showSignin = ref(false);
|
||||
@@ -24,8 +26,12 @@ const roomNumber = ref("");
|
||||
const data = ref({});
|
||||
const roomID = ref("");
|
||||
const loading = ref(false);
|
||||
const showLimitModal = ref(false);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
const challengeLimitText = computed(() =>
|
||||
getLimitCountText("约战", dailyCount.value.challenge)
|
||||
);
|
||||
|
||||
const enterRoom = debounce(async (number) => {
|
||||
if (loading.value) return;
|
||||
@@ -67,9 +73,37 @@ const enterRoom = debounce(async (number) => {
|
||||
});
|
||||
const onCreateRoom = async () => {
|
||||
if (!canEenter(user.value, device.value, online.value)) return;
|
||||
const countData = await loadDailyCount();
|
||||
if (isLimitReached(countData.challenge)) {
|
||||
showLimitModal.value = true;
|
||||
return;
|
||||
}
|
||||
warnning.value = "";
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const closeLimitModal = () => {
|
||||
showLimitModal.value = false;
|
||||
};
|
||||
|
||||
const goVipPage = () => {
|
||||
showLimitModal.value = false;
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
});
|
||||
};
|
||||
|
||||
const loadDailyCount = async () => {
|
||||
if (!user.value.id) return dailyCount.value;
|
||||
try {
|
||||
const result = await getDailyCountAPI();
|
||||
updateDailyCount(result);
|
||||
return result || dailyCount.value;
|
||||
} catch (error) {
|
||||
console.log("load daily count error", error);
|
||||
return dailyCount.value;
|
||||
}
|
||||
};
|
||||
const onSignin = () => {
|
||||
if (roomID.value && user.value.id) enterRoom(roomID.value);
|
||||
showSignin.value = false;
|
||||
@@ -83,7 +117,10 @@ const goMyRecord = () => {
|
||||
};
|
||||
onShow(async () => {
|
||||
if (user.value.id) {
|
||||
const result = await getBattleDataAPI();
|
||||
const [result] = await Promise.all([
|
||||
getBattleDataAPI(),
|
||||
loadDailyCount(),
|
||||
]);
|
||||
data.value = result;
|
||||
}
|
||||
});
|
||||
@@ -168,8 +205,8 @@ onLoad(async (options) => {
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view class="pp-text">
|
||||
<!-- 今日约战次数:2/2 -->
|
||||
<view v-if="challengeLimitText" class="pp-text">
|
||||
{{ challengeLimitText }}
|
||||
</view>
|
||||
<SButton width="80%" :rounded="30" :onClick="() => $clickSound(onCreateRoom)">
|
||||
创建约战房
|
||||
@@ -186,6 +223,14 @@ onLoad(async (options) => {
|
||||
<Signin :show="showSignin" :onClose="onSignin" />
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import AppFooter from "@/components/AppFooter.vue";
|
||||
import UserHeader from "@/components/UserHeader.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
import OtaModal from "@/components/OtaModal.vue";
|
||||
|
||||
import {
|
||||
checkUserBindAPI,
|
||||
getAppConfig,
|
||||
getDeviceBatteryAPI,
|
||||
getHardwareBoxTaskStatusAPI,
|
||||
getHardwareBoxVersionAPI,
|
||||
getHomeData,
|
||||
getMyDevicesAPI,
|
||||
getScoreRankList,
|
||||
sendHardwareBoxUpdateAPI,
|
||||
silentLoginAPI,
|
||||
} from "@/apis";
|
||||
import {topThreeColors} from "@/constants";
|
||||
@@ -37,6 +41,208 @@ const showModal = ref(false);
|
||||
const showGuide = ref(false);
|
||||
const scoreRankList = ref([]);
|
||||
|
||||
// OTA 相关
|
||||
const otaVisible = ref(false);
|
||||
const otaState = ref("new_version");
|
||||
const otaProgress = ref(0);
|
||||
const otaInfo = ref({
|
||||
versionNumber: "",
|
||||
versionInfo: "",
|
||||
resourceUrl: "",
|
||||
forceUpdate: false,
|
||||
});
|
||||
const isStartingOta = ref(false);
|
||||
let otaProgressTimer = null;
|
||||
let otaStatusTimer = null;
|
||||
let otaTimeoutTimer = null;
|
||||
|
||||
// 清理首页 OTA 更新定时器,避免弹窗关闭或页面卸载后继续轮询。
|
||||
const clearOtaUpdateTimers = () => {
|
||||
clearInterval(otaProgressTimer);
|
||||
clearTimeout(otaStatusTimer);
|
||||
clearTimeout(otaTimeoutTimer);
|
||||
otaProgressTimer = null;
|
||||
otaStatusTimer = null;
|
||||
otaTimeoutTimer = null;
|
||||
};
|
||||
|
||||
// 启动首页 OTA 本地进度动画,最终成功失败以后端任务状态为准。
|
||||
const startOtaProgressAnimation = () => {
|
||||
clearInterval(otaProgressTimer);
|
||||
otaProgressTimer = setInterval(() => {
|
||||
if (otaProgress.value >= 90) {
|
||||
clearInterval(otaProgressTimer);
|
||||
return;
|
||||
}
|
||||
const increment = Math.max(0.5, 2 - otaProgress.value / 60);
|
||||
otaProgress.value = Math.min(90, otaProgress.value + increment);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 获取并保存后端返回的 OTA 版本信息,供弹窗展示和更新接口使用。
|
||||
const applyOtaVersionInfo = (versionInfo) => {
|
||||
otaInfo.value = {
|
||||
versionNumber: versionInfo?.versionNumber || "",
|
||||
versionInfo: versionInfo?.versionInfo || "",
|
||||
resourceUrl: versionInfo?.resourceUrl || "",
|
||||
forceUpdate: Number(versionInfo?.forceUpdate) === 1,
|
||||
};
|
||||
};
|
||||
|
||||
// 检查当前设备盒子是否存在可升级版本。
|
||||
const checkOtaUpdate = async () => {
|
||||
let versionInfo;
|
||||
try {
|
||||
versionInfo = await getHardwareBoxVersionAPI();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
if (!versionInfo?.needUpdate) return;
|
||||
applyOtaVersionInfo(versionInfo);
|
||||
|
||||
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
|
||||
const now = Date.now();
|
||||
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
|
||||
otaState.value = "new_version";
|
||||
otaVisible.value = true;
|
||||
};
|
||||
|
||||
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
|
||||
const getOtaWifiUrl = () => {
|
||||
const { versionNumber, resourceUrl } = otaInfo.value;
|
||||
const query = [
|
||||
`versionNumber=${encodeURIComponent(versionNumber)}`,
|
||||
`resourceUrl=${encodeURIComponent(resourceUrl)}`,
|
||||
].join("&");
|
||||
return `/pages/ota-wifi?${query}`;
|
||||
};
|
||||
|
||||
// 处理 OTA 弹窗暂不更新,强制更新时不允许关闭。
|
||||
const handleOtaDismiss = () => {
|
||||
if (otaInfo.value.forceUpdate) return;
|
||||
uni.setStorageSync("ota_dismissed_at", Date.now());
|
||||
otaVisible.value = false;
|
||||
};
|
||||
|
||||
// 将首页 OTA 直连更新流程标记为失败。
|
||||
const failHomeOtaUpdate = () => {
|
||||
clearOtaUpdateTimers();
|
||||
isStartingOta.value = false;
|
||||
otaState.value = "update_failure";
|
||||
otaVisible.value = true;
|
||||
};
|
||||
|
||||
// 将首页 OTA 直连更新流程标记为成功。
|
||||
const completeHomeOtaUpdate = () => {
|
||||
clearOtaUpdateTimers();
|
||||
isStartingOta.value = false;
|
||||
otaProgress.value = 100;
|
||||
setTimeout(() => {
|
||||
otaState.value = "update_success";
|
||||
otaVisible.value = true;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 轮询首页直接发起的 OTA 更新任务状态。
|
||||
const pollHomeOtaTaskStatus = (taskId) => {
|
||||
clearTimeout(otaStatusTimer);
|
||||
otaStatusTimer = setTimeout(async () => {
|
||||
try {
|
||||
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
|
||||
const status = Number(taskStatus?.status);
|
||||
if (status === 2) {
|
||||
completeHomeOtaUpdate();
|
||||
return;
|
||||
}
|
||||
if (status === 3) {
|
||||
failHomeOtaUpdate();
|
||||
return;
|
||||
}
|
||||
if (status === 0 || status === 1) {
|
||||
pollHomeOtaTaskStatus(taskId);
|
||||
return;
|
||||
}
|
||||
failHomeOtaUpdate();
|
||||
} catch (err) {
|
||||
failHomeOtaUpdate();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 设备盒子已连 WiFi 时,从首页直接传空 WiFi 信息发起 OTA 更新。
|
||||
const startHomeOtaUpdate = async () => {
|
||||
otaState.value = "update_progress";
|
||||
otaVisible.value = true;
|
||||
otaProgress.value = 0;
|
||||
startOtaProgressAnimation();
|
||||
otaTimeoutTimer = setTimeout(() => {
|
||||
if (otaState.value === "update_progress") {
|
||||
failHomeOtaUpdate();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
try {
|
||||
const updateResult = await sendHardwareBoxUpdateAPI({
|
||||
versionNumber: otaInfo.value.versionNumber,
|
||||
wifiSsid: "",
|
||||
wifiPassword: "",
|
||||
resourceUrl: otaInfo.value.resourceUrl,
|
||||
});
|
||||
if (!updateResult?.taskId) {
|
||||
failHomeOtaUpdate();
|
||||
return;
|
||||
}
|
||||
pollHomeOtaTaskStatus(updateResult.taskId);
|
||||
} catch (err) {
|
||||
failHomeOtaUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击立即更新时先判断设备是否在线并已通过 WiFi 联网,已联网则首页直接更新,否则跳转 WiFi 页面。
|
||||
const handleOtaUpdate = async () => {
|
||||
if (isStartingOta.value) return;
|
||||
isStartingOta.value = true;
|
||||
let deviceStatus;
|
||||
try {
|
||||
deviceStatus = await getDeviceBatteryAPI();
|
||||
} catch (err) {
|
||||
isStartingOta.value = false;
|
||||
uni.showToast({
|
||||
title: "获取设备状态失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (deviceStatus?.online !== true) {
|
||||
isStartingOta.value = false;
|
||||
uni.showToast({
|
||||
title: "请先开启智能弓",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(deviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||
startHomeOtaUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
isStartingOta.value = false;
|
||||
otaVisible.value = false;
|
||||
uni.navigateTo({ url: getOtaWifiUrl() });
|
||||
};
|
||||
|
||||
// 处理 OTA 更新成功后的完成按钮,关闭结果弹窗。
|
||||
const handleOtaDone = () => {
|
||||
otaVisible.value = false;
|
||||
};
|
||||
|
||||
// 处理 OTA 更新失败后的重试按钮,重新走立即更新判断流程。
|
||||
const handleOtaRetry = () => {
|
||||
handleOtaUpdate();
|
||||
};
|
||||
|
||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||
const getScoreRankData = (result) => {
|
||||
if (Array.isArray(result)) return result;
|
||||
@@ -64,10 +270,18 @@ const toRankListPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
onShow(async (options) => {
|
||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
const token = uni.getStorageSync(`${env}_token`);
|
||||
|
||||
// 检查是否从 OTA 更新页面返回
|
||||
if (options && options.updateResult) {
|
||||
otaState.value = options.updateResult;
|
||||
otaVisible.value = true;
|
||||
} else if (token || user.value.id) {
|
||||
await checkOtaUpdate();
|
||||
}
|
||||
|
||||
if (!user.value.id && !token) {
|
||||
// showModal.value = true;
|
||||
// try {
|
||||
@@ -141,6 +355,10 @@ onMounted(async () => {
|
||||
console.log("全局配置:", config);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearOtaUpdateTimers();
|
||||
});
|
||||
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: "智能真弓:实时捕捉+毫秒级同步,弓箭选手全球竞技!", // 分享卡片的标题
|
||||
@@ -161,6 +379,21 @@ onShareTimeline(() => {
|
||||
|
||||
<template>
|
||||
<Container :isHome="true" :showBackToGame="true">
|
||||
<!-- OTA 升级弹窗:使用 visible 控制显隐,description 为副标题,changelog 为详细说明 -->
|
||||
<OtaModal
|
||||
:visible="otaVisible"
|
||||
:state="otaState"
|
||||
:version="otaInfo.versionNumber"
|
||||
:progress="otaProgress"
|
||||
:description="''"
|
||||
:changelog="otaInfo.versionInfo"
|
||||
:forceUpdate="otaInfo.forceUpdate"
|
||||
@update="handleOtaUpdate"
|
||||
@skip="handleOtaDismiss"
|
||||
@close="handleOtaDismiss"
|
||||
@done="handleOtaDone"
|
||||
@retry="handleOtaRetry"
|
||||
/>
|
||||
<view class="container">
|
||||
<view class="top-theme">
|
||||
<!-- <image
|
||||
|
||||
@@ -3,12 +3,15 @@ import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Matching from "@/components/Matching.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import { matchGameAPI, getBattleAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { isLimitError } from "@/util";
|
||||
|
||||
const gameType = ref(0);
|
||||
const teamSize = ref(0);
|
||||
const onComplete = ref(null);
|
||||
const showLimitModal = ref(false);
|
||||
|
||||
/** 匹配超时计时器,用于检测 WS 消息丢失或真正超时 */
|
||||
const matchTimeoutTimer = ref(null);
|
||||
@@ -58,6 +61,18 @@ async function stopMatch() {
|
||||
uni.$showHint(3);
|
||||
}
|
||||
|
||||
const closeLimitModal = () => {
|
||||
showLimitModal.value = false;
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const goVipPage = () => {
|
||||
showLimitModal.value = false;
|
||||
uni.redirectTo({
|
||||
url: "/pages/member/be-vip",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消匹配,带容错处理:
|
||||
* - 取消成功 → 返回大厅
|
||||
@@ -136,10 +151,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
onShow(async () => {
|
||||
if (gameType.value && teamSize.value) {
|
||||
matchGameAPI(true, gameType.value, teamSize.value);
|
||||
try {
|
||||
await matchGameAPI(true, gameType.value, teamSize.value);
|
||||
// 启动超时计时器,防止 WS 消息丢失或长时间无对手导致用户卡死
|
||||
clearMatchTimeout();
|
||||
matchTimeoutTimer.value = setTimeout(handleMatchTimeout, MATCH_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
clearMatchTimeout();
|
||||
if (isLimitError(error)) {
|
||||
showLimitModal.value = true;
|
||||
return;
|
||||
}
|
||||
uni.navigateBack();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +178,14 @@ onHide(() => {
|
||||
<Matching :stopMatch="stopMatch" :onComplete="onComplete" />
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日排位赛次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -78,7 +78,7 @@ import Container from "@/components/Container.vue";
|
||||
<text class="table-cell">专享</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">会员标识</text>
|
||||
<text class="table-cell table-cell--feature">昵称美化</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">专享</text>
|
||||
<text class="table-cell">专享</text>
|
||||
|
||||
@@ -103,6 +103,11 @@ const unbindDevice = async () => {
|
||||
clearDevice();
|
||||
};
|
||||
|
||||
/** 连接wifi跳转到wifi列表页面 */
|
||||
const joinWifi = () => {
|
||||
uni.navigateTo({ url: "/pages/ota-wifi" });
|
||||
};
|
||||
|
||||
const toDeviceIntroPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/device-intro",
|
||||
@@ -322,6 +327,11 @@ onShow(async () => {
|
||||
>解绑</SButton
|
||||
>
|
||||
</view>
|
||||
<view :style="{ marginTop: '20rpx' }">
|
||||
<SButton :onClick="() => $clickSound(joinWifi)" width="80vw" :rounded="40"
|
||||
>设备连接WIFI</SButton
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
@@ -81,9 +81,17 @@ const loading = ref(false);
|
||||
const shareImage = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await generateShareImage("shareImageCanvas", record.value);
|
||||
await wxShare("shareImageCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(async (options) => {
|
||||
|
||||
@@ -15,11 +15,22 @@ const list = ref([]);
|
||||
const mine = ref({
|
||||
averageRing: 0,
|
||||
});
|
||||
const sharing = ref(false);
|
||||
|
||||
const shareImage = async () => {
|
||||
if (!mine.value.id) return;
|
||||
if (!mine.value.id || sharing.value) return;
|
||||
sharing.value = true;
|
||||
try {
|
||||
await sharePointData("shareCanvas", mine.value);
|
||||
await wxShare("shareCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
sharing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const tips = ref("");
|
||||
const targetType = ref(1);
|
||||
const sharing = ref(false);
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.target) {
|
||||
@@ -116,8 +117,19 @@ async function onComplete() {
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
if (sharing.value) return;
|
||||
sharing.value = true;
|
||||
try {
|
||||
await sharePractiseData("shareCanvas", 2, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
sharing.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function onAudioEnded(s) {
|
||||
|
||||
@@ -38,6 +38,7 @@ const practiseResult = ref({});
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const targetType = ref(1);
|
||||
const sharing = ref(false);
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.target) {
|
||||
@@ -131,8 +132,19 @@ async function onComplete() {
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
if (sharing.value) return;
|
||||
sharing.value = true;
|
||||
try {
|
||||
await sharePractiseData("shareCanvas", 3, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
sharing.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -3,21 +3,23 @@ import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import { topThreeColors } from "@/constants";
|
||||
import {
|
||||
getDailyCountAPI,
|
||||
getSeasonList,
|
||||
getSeasonStats,
|
||||
getScoreRankList,
|
||||
getTenRingRankList,
|
||||
getMvpRankList,
|
||||
} from "@/apis";
|
||||
import { canEenter } from "@/util";
|
||||
import { canEenter, getLimitCountText, isLimitReached } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { user, device, online, game } = storeToRefs(store);
|
||||
const { getLvlName } = store;
|
||||
const { user, device, online, game, dailyCount } = storeToRefs(store);
|
||||
const { getLvlName, updateDailyCount } = store;
|
||||
|
||||
const defaultSeasonStats = {
|
||||
nickName: "",
|
||||
@@ -55,8 +57,12 @@ const rankLoading = ref(false);
|
||||
const scoreRankList = ref([]);
|
||||
const mvpRankList = ref([]);
|
||||
const tenRingRankList = ref([]);
|
||||
const showLimitModal = ref(false);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
const rankedLimitText = computed(() =>
|
||||
getLimitCountText("排位", dailyCount.value.ranked)
|
||||
);
|
||||
|
||||
const isMember = (item = {}) => item.vip === true || item.sVip === true;
|
||||
|
||||
@@ -115,12 +121,40 @@ const toMatchPage = async (gameType, teamSize) => {
|
||||
uni.$showHint(1);
|
||||
return;
|
||||
}
|
||||
const countData = await loadDailyCount();
|
||||
if (isLimitReached(countData.ranked)) {
|
||||
showLimitModal.value = true;
|
||||
return;
|
||||
}
|
||||
await uni.$checkAudio();
|
||||
uni.navigateTo({
|
||||
url: `/pages/match-page?gameType=${gameType}&teamSize=${teamSize}`,
|
||||
});
|
||||
};
|
||||
|
||||
const closeLimitModal = () => {
|
||||
showLimitModal.value = false;
|
||||
};
|
||||
|
||||
const goVipPage = () => {
|
||||
showLimitModal.value = false;
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
});
|
||||
};
|
||||
|
||||
const loadDailyCount = async () => {
|
||||
if (!user.value.id) return dailyCount.value;
|
||||
try {
|
||||
const result = await getDailyCountAPI();
|
||||
updateDailyCount(result);
|
||||
return result || dailyCount.value;
|
||||
} catch (error) {
|
||||
console.log("load daily count error", error);
|
||||
return dailyCount.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toMyGrowthPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/my-growth",
|
||||
@@ -246,7 +280,10 @@ const onChangeSeason = async (seasonId, name) => {
|
||||
// 页面显示时先拿赛季列表,再拉当前赛季统计和默认榜单数据。
|
||||
onShow(async () => {
|
||||
try {
|
||||
const seasonResult = await getSeasonList();
|
||||
const [seasonResult] = await Promise.all([
|
||||
getSeasonList(),
|
||||
loadDailyCount(),
|
||||
]);
|
||||
seasonData.value = seasonResult.list || [];
|
||||
|
||||
if (!seasonData.value.length) {
|
||||
@@ -283,12 +320,12 @@ onShow(async () => {
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:title="'排位赛'"
|
||||
:title="rankedLimitText ? rankedLimitText : '排位赛'"
|
||||
:titleStyle="rankedLimitText ? { fontSize: '24rpx', fontWeight: 'normal' } : {}"
|
||||
:showBackToGame="true"
|
||||
:bgType="6"
|
||||
>
|
||||
<view class="battle-types-box">
|
||||
<!-- :title="'今日排位次数:2/2'" :titleStyle="{ fontSize: '24rpx', fontWeight: 'normal' }" -->
|
||||
<view class="battle-types">
|
||||
<view class="first">
|
||||
<image src="../static/rank/battle-choose.png" mode="widthFix" />
|
||||
@@ -585,6 +622,14 @@ onShow(async () => {
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日排位赛次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 277 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 603 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 457 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 494 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 548 B |
@@ -26,6 +26,17 @@ const getDefaultGame = () => ({
|
||||
tips: "",
|
||||
});
|
||||
|
||||
const getDefaultDailyCount = () => ({
|
||||
challenge: {
|
||||
count: 0,
|
||||
limit: -1,
|
||||
},
|
||||
ranked: {
|
||||
count: 0,
|
||||
limit: -1,
|
||||
},
|
||||
});
|
||||
|
||||
const getLvlName = (rankLvl, rankList = []) => {
|
||||
if (!rankList) return;
|
||||
let lvlName = "";
|
||||
@@ -99,6 +110,7 @@ export default defineStore("store", {
|
||||
totalShot: 0, // 轮次总箭数(用于 HeaderProgress 恢复状态)
|
||||
tips: "", // 当前提示文案(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题)
|
||||
},
|
||||
dailyCount: getDefaultDailyCount(),
|
||||
}),
|
||||
|
||||
// 计算属性
|
||||
@@ -171,12 +183,19 @@ export default defineStore("store", {
|
||||
updateRoomNumber(number) {
|
||||
this.game.roomNumber = number;
|
||||
},
|
||||
updateDailyCount(data = {}) {
|
||||
this.dailyCount = {
|
||||
...getDefaultDailyCount(),
|
||||
...(data || {}),
|
||||
};
|
||||
},
|
||||
clearSessionState() {
|
||||
this.$patch({
|
||||
user: getDefaultUser(),
|
||||
device: getDefaultDevice(),
|
||||
online: false,
|
||||
game: getDefaultGame(),
|
||||
dailyCount: getDefaultDailyCount(),
|
||||
});
|
||||
uni.removeStorageSync(PERSISTED_STORE_KEY);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -11,11 +11,13 @@ export const debounce = (fn, delay = 300) => {
|
||||
let timer = null;
|
||||
return async (...args) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
timer = null;
|
||||
}
|
||||
@@ -24,6 +26,28 @@ export const debounce = (fn, delay = 300) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const isUnlimitedLimit = (item = {}) => Number(item.limit) === -1;
|
||||
|
||||
export const getLimitCountText = (label, item = {}) => {
|
||||
if (!item || isUnlimitedLimit(item)) return "";
|
||||
const count = Number(item.count || 0);
|
||||
const limit = Number(item.limit || 0);
|
||||
return `今日${label}次数:${count}/${limit}`;
|
||||
};
|
||||
|
||||
export const isLimitReached = (item = {}) => {
|
||||
if (!item || isUnlimitedLimit(item)) return false;
|
||||
return Number(item.count || 0) >= Number(item.limit || 0);
|
||||
};
|
||||
|
||||
export const isLimitError = (error) => {
|
||||
const message = typeof error === "string" ? error : error?.message;
|
||||
return String(message || "").includes("已达上限");
|
||||
};
|
||||
|
||||
const isShareCancel = (error) =>
|
||||
String(error?.errMsg || error || "").toLowerCase().includes("cancel");
|
||||
|
||||
export const wxShare = async (canvasId = "shareCanvas") => {
|
||||
try {
|
||||
// 先尝试按 id 查找 <canvas type="2d"> 节点
|
||||
@@ -54,35 +78,63 @@ export const wxShare = async (canvasId = "shareCanvas") => {
|
||||
quality: 1,
|
||||
success: (r) => {
|
||||
const p = r.tempFilePath || r.apFilePath || r.filePath;
|
||||
if (!p) {
|
||||
reject(new Error("share image path is empty"));
|
||||
return;
|
||||
}
|
||||
resolve(p);
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.showShareImageMenu({
|
||||
entrancePath: "pages/index",
|
||||
path: tempPath,
|
||||
success: resolve,
|
||||
fail: (error) => {
|
||||
if (isShareCancel(error)) {
|
||||
resolve({ canceled: true });
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
// 回退:旧版非 2D 画布(通过 canvasId 导出)
|
||||
const res = await uni.canvasToTempFilePath({
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
uni.canvasToTempFilePath({
|
||||
canvasId,
|
||||
fileType: "png",
|
||||
quality: 1,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
const tempPath = res.tempFilePath || res.apFilePath || res.filePath;
|
||||
if (!tempPath) {
|
||||
throw new Error("share image path is empty");
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.showShareImageMenu({
|
||||
entrancePath: "pages/index",
|
||||
path: res.tempFilePath,
|
||||
path: tempPath,
|
||||
success: resolve,
|
||||
fail: (error) => {
|
||||
if (isShareCancel(error)) {
|
||||
resolve({ canceled: true });
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
return res.tempFilePath;
|
||||
});
|
||||
return tempPath;
|
||||
} catch (error) {
|
||||
console.log("生成图片失败:", error);
|
||||
uni.showToast({
|
||||
title: "生成图片失败",
|
||||
icon: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||