Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9699f1356f | ||
|
|
899a222e3f | ||
|
|
ca727ec1f7 | ||
|
|
aa33a931b1 | ||
|
|
bac84536c7 | ||
|
|
f044a95872 | ||
|
|
94c46bb7df | ||
|
|
318b952a9b | ||
|
|
05f73acf47 | ||
|
|
14437bb5a7 | ||
|
|
eca15178f4 | ||
|
|
f16b486a89 | ||
|
|
6c69e14630 | ||
|
|
9eb6ef7cf1 | ||
|
|
34118d59d3 | ||
|
|
00f84fc3e4 | ||
|
|
b82474913a | ||
|
|
5bb9df8a03 | ||
|
|
9d9f005ca3 | ||
|
|
bad336c7d4 | ||
|
|
605a0b2d91 | ||
|
|
cee09cd7fa | ||
|
|
c9bfa6792b | ||
|
|
ae3ba60191 | ||
|
|
b0ddf147aa | ||
|
|
f4b37fba00 | ||
|
|
f8bf9fdb63 | ||
|
|
3caa53d4be | ||
|
|
21ed8066aa | ||
|
|
f4f63954eb | ||
|
|
78fd64c697 | ||
|
|
b89744802a | ||
|
|
169e2e4a51 | ||
|
|
fd6be25ba5 | ||
|
|
19a4631df3 | ||
|
|
bad3c8ff9a | ||
|
|
9ad2c5e12e | ||
|
|
9de435a369 | ||
|
|
5357f131de |
@@ -4,6 +4,10 @@
|
||||
"libVersion": "3.7.7",
|
||||
"packOptions": {
|
||||
"ignore": [
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "static/coin"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"value": "static/common/dialog-light.png"
|
||||
|
||||
@@ -264,11 +264,6 @@ function buildSchema({ messageName, definition, messages, enumNames }) {
|
||||
}
|
||||
|
||||
const repeated = rule === "repeated";
|
||||
if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
|
||||
throw new Error(
|
||||
`${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
|
||||
);
|
||||
}
|
||||
|
||||
const field = isMessage
|
||||
? { name: fieldName, kind: "message", type: typeName }
|
||||
|
||||
@@ -592,14 +592,20 @@ export const connectDeviceWifiAPI = async (ssid, password) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
||||
export const getHardwareBoxVersionAPI = async () => {
|
||||
return request("GET", "/user/hardwareBox/version");
|
||||
// 查询设备当前联网类型及已连接的 WiFi 名称。
|
||||
export const getDeviceWifiStatusAPI = async () => {
|
||||
return request("GET", "/user/device/wifiStatus", {}, BASE_URL, [0], {
|
||||
showErrorToast: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||
export const sendHardwareBoxUpdateAPI = async (data) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
||||
export const sendHardwareBoxUpdateAPI = async ({versionNumber, wifiSsid, wifiPassword}) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", {
|
||||
versionNumber,
|
||||
wifiSsid,
|
||||
wifiPassword,
|
||||
});
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
@@ -764,6 +770,21 @@ export const getGiftDetailAPI = (id) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 分页获取启用门店及当前用户在各店的会员状态。
|
||||
export const getStoreVipListAPI = ({page = 1, pageSize = 20} = {}) => {
|
||||
return request("GET", "/store/list", {page, pageSize});
|
||||
};
|
||||
|
||||
// 查询当前用户在指定门店的会员状态及权益剩余次数。
|
||||
export const getStoreBenefitAPI = (storeId) => {
|
||||
return request("GET", "/store/benefit", {storeId});
|
||||
};
|
||||
|
||||
// 获取体验中心 VIP 介绍纯文本,无需登录。
|
||||
export const getStoreVipIntroAPI = () => {
|
||||
return request("GET", "/store/vip-intro");
|
||||
};
|
||||
|
||||
// 根据用户定位分页获取附近门店。
|
||||
export const getNearbyStoresAPI = ({
|
||||
longitude,
|
||||
|
||||
@@ -18,6 +18,10 @@ const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
|
||||
|
||||
// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。
|
||||
export const getTrainingEndAudioKey = (completed) =>
|
||||
completed === true ? "通关成功" : "通关失败";
|
||||
|
||||
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
||||
if (!shootData || typeof shootData !== "object") return [];
|
||||
const ring = Number(shootData.ring);
|
||||
@@ -201,6 +205,10 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
|
||||
练习结束:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
|
||||
通关成功:
|
||||
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3",
|
||||
通关失败:
|
||||
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3",
|
||||
射箭声音:
|
||||
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
||||
命中:
|
||||
|
||||
@@ -7,9 +7,13 @@ const tabs = [
|
||||
|
||||
function handleTabClick(index) {
|
||||
if (index === 0) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
uni.showToast({
|
||||
title: "功能尚未开启",
|
||||
icon: "none",
|
||||
});
|
||||
// uni.navigateTo({
|
||||
// url: "/pages/member/be-vip",
|
||||
// });
|
||||
}
|
||||
if (index === 1) {
|
||||
uni.navigateTo({
|
||||
|
||||
@@ -14,8 +14,13 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "normal",
|
||||
},
|
||||
contentHeight: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const getContentHeight = () => {
|
||||
if (props.contentHeight) return props.contentHeight;
|
||||
if (props.mode === "tall") return "50vw";
|
||||
if (props.mode === "square") return "74vw";
|
||||
return "36vw";
|
||||
@@ -82,8 +87,7 @@ const getContentHeight = () => {
|
||||
position: absolute;
|
||||
width: 80vw;
|
||||
left: -7%;
|
||||
bottom: -18vw;
|
||||
bottom: -8rpx;
|
||||
z-index: -1;
|
||||
transform: translateY(-75px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,10 +46,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 指定环数,1 到 10;无效值表示高亮整个区域。
|
||||
activeRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// 指定一个或多个环数,空数组表示高亮整个区域。
|
||||
activeRings: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
|
||||
highlightRefreshToken: {
|
||||
@@ -192,6 +192,25 @@ const getPositiveInteger = (value) => {
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
// 过滤、去重并合并连续环,避免 3、4 环同时高亮时绘制中间描边。
|
||||
const getActiveRingRanges = (ringCount) => {
|
||||
const rings = [...new Set(
|
||||
(Array.isArray(props.activeRings) ? props.activeRings : [])
|
||||
.map(getPositiveInteger)
|
||||
.filter((ring) => ring >= 1 && ring <= ringCount)
|
||||
)].sort((first, second) => first - second);
|
||||
|
||||
return rings.reduce((ranges, ring) => {
|
||||
const lastRange = ranges[ranges.length - 1];
|
||||
if (lastRange && ring === lastRange.end + 1) {
|
||||
lastRange.end = ring;
|
||||
} else {
|
||||
ranges.push({ start: ring, end: ring });
|
||||
}
|
||||
return ranges;
|
||||
}, []);
|
||||
};
|
||||
|
||||
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
|
||||
const getSectorAngles = (sector, sectorCount) => {
|
||||
const count = getPositiveInteger(sectorCount);
|
||||
@@ -256,7 +275,7 @@ const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
|
||||
// 高亮后端指定区域;activeRings 非空时高亮该区域内的一个或多个环。
|
||||
const drawSectorHighlight = (
|
||||
ctx,
|
||||
centerX,
|
||||
@@ -274,19 +293,21 @@ const drawSectorHighlight = (
|
||||
);
|
||||
if (safeRevealProgress <= 0) return;
|
||||
|
||||
const ring = getPositiveInteger(props.activeRing);
|
||||
const hasActiveRing = ring >= 1 && ring <= config.ringCount;
|
||||
const innerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount - ring) / config.ringCount)
|
||||
: 0;
|
||||
const outerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
|
||||
: targetRadius;
|
||||
const ringRanges = getActiveRingRanges(config.ringCount);
|
||||
const highlightRanges = ringRanges.length > 0
|
||||
? ringRanges
|
||||
: [{ start: 1, end: config.ringCount }];
|
||||
const style = {
|
||||
...defaultHighlightStyle,
|
||||
...props.highlightStyle,
|
||||
};
|
||||
|
||||
highlightRanges.forEach((range) => {
|
||||
const innerRadius =
|
||||
targetRadius * ((config.ringCount - range.end) / config.ringCount);
|
||||
const outerRadius =
|
||||
targetRadius * ((config.ringCount + 1 - range.start) / config.ringCount);
|
||||
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
@@ -300,6 +321,7 @@ const drawSectorHighlight = (
|
||||
style.strokeColor,
|
||||
Math.max(1, targetRadius * style.lineWidthRatio)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 从正上方开始顺时针绘制所有区域边界。
|
||||
@@ -420,7 +442,7 @@ const getDrawKey = (width, height) => {
|
||||
showRingLabels: props.showRingLabels,
|
||||
sectorCount: props.sectorCount,
|
||||
activeSector: props.activeSector,
|
||||
activeRing: props.activeRing,
|
||||
activeRings: props.activeRings,
|
||||
showSectorLabels: props.showSectorLabels,
|
||||
targetStyleConfig: props.targetStyleConfig,
|
||||
crosshairStyle: props.crosshairStyle,
|
||||
@@ -591,7 +613,7 @@ watch(
|
||||
props.showRingLabels,
|
||||
props.sectorCount,
|
||||
props.activeSector,
|
||||
props.activeRing,
|
||||
props.activeRings,
|
||||
props.highlightRefreshToken,
|
||||
props.showSectorLabels,
|
||||
props.highlightOnly,
|
||||
|
||||
@@ -13,6 +13,15 @@ const phaseTextMap = {
|
||||
installing: "正在安装固件",
|
||||
};
|
||||
|
||||
// 设备详情中的 latestVersion 是升级目标,version 是当前固件版本。
|
||||
export const getOtaInfoFromDetail = (detail) => ({
|
||||
versionNumber: String(detail?.latestVersion ?? "").trim(),
|
||||
currentVersion: String(detail?.version ?? "").trim(),
|
||||
versionInfo: String(detail?.versionInfo ?? "").trim(),
|
||||
needUpdate: detail?.needUpdate === true || Number(detail?.needUpdate) === 1,
|
||||
forceUpdate: detail?.forceUpdate === true || Number(detail?.forceUpdate) === 1,
|
||||
});
|
||||
|
||||
// OTA 状态由首页、WiFi 设置页和我的设备页共享,页面切换后仍可继续接收进度。
|
||||
const updating = ref(false);
|
||||
const progress = ref(0);
|
||||
@@ -115,6 +124,7 @@ function handleSocketMessage(message) {
|
||||
} else if (!isCurrentVersionMessage(message.data)) {
|
||||
return;
|
||||
}
|
||||
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||
|
||||
const nextProgress = Math.min(
|
||||
100,
|
||||
@@ -138,6 +148,7 @@ function handleSocketMessage(message) {
|
||||
} else if (!isCurrentVersionMessage(message.data)) {
|
||||
return;
|
||||
}
|
||||
if (!targetVersion && messageVersion) targetVersion = messageVersion;
|
||||
|
||||
if (message.data?.status === "success") {
|
||||
finishUpdate("success");
|
||||
@@ -160,16 +171,20 @@ export const useOtaUpdate = () => {
|
||||
|
||||
const startUpdate = async ({
|
||||
versionNumber,
|
||||
resourceUrl,
|
||||
wifiSsid = "",
|
||||
wifiPassword = "",
|
||||
onSuccess,
|
||||
}) => {
|
||||
if (updating.value) return false;
|
||||
const nextVersion = String(versionNumber || "").trim();
|
||||
if (!nextVersion) {
|
||||
uni.showToast({ title: "缺少固件目标版本", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
|
||||
updateRunId += 1;
|
||||
const runId = updateRunId;
|
||||
targetVersion = String(versionNumber || "");
|
||||
targetVersion = nextVersion;
|
||||
lastFinishedVersion = "";
|
||||
successCallback = onSuccess || null;
|
||||
resultVisible.value = false;
|
||||
@@ -188,7 +203,6 @@ export const useOtaUpdate = () => {
|
||||
versionNumber: targetVersion,
|
||||
wifiSsid,
|
||||
wifiPassword,
|
||||
resourceUrl,
|
||||
});
|
||||
if (runId !== updateRunId || !updating.value) return false;
|
||||
if (!updateResult?.taskId) {
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"appid" : "wxa8f5989dcd45cc23",
|
||||
"packOptions" : {
|
||||
"ignore" : [
|
||||
{
|
||||
"type" : "folder",
|
||||
"value" : "static/coin"
|
||||
},
|
||||
{
|
||||
"type" : "file",
|
||||
"value" : "static/common/dialog-light.png"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getPrecisionShotAudioKeys,
|
||||
getRhythmShotAudioKeys,
|
||||
getStabilityShotAudioKeys,
|
||||
getTrainingEndAudioKey,
|
||||
getTrainingStartAudioKey,
|
||||
STABILITY_ENERGY_50_AUDIO_KEY,
|
||||
STABILITY_ENERGY_70_AUDIO_KEY,
|
||||
@@ -603,6 +604,10 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
|
||||
return ["比赛结束"];
|
||||
case ServerMessageType.SERVER_MSG_PRACTICE_END:
|
||||
// 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。
|
||||
if (currentContext?.practiceEndAudioKey === "练习结束") {
|
||||
return [getTrainingEndAudioKey(businessMessage?.completed)];
|
||||
}
|
||||
return [currentContext?.practiceEndAudioKey || "比赛结束"];
|
||||
case ServerMessageType.SERVER_MSG_CHECK:
|
||||
return getTestDistanceAudioKeys(businessMessage?.shootData);
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
{
|
||||
"root": "pages/coin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "vip-intro"
|
||||
},
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
@@ -156,6 +159,9 @@
|
||||
{
|
||||
"path": "device-bind-failure"
|
||||
},
|
||||
{
|
||||
"path": "device-already-bound"
|
||||
},
|
||||
{
|
||||
"path": "device-intro"
|
||||
},
|
||||
|
||||
@@ -38,13 +38,12 @@ defineProps({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 706rpx;
|
||||
width: 100%;
|
||||
height: 60rpx;
|
||||
margin-top: 0;
|
||||
border: 2rpx solid rgba(255, 217, 71, 0.25);
|
||||
border-radius: 12rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(255, 217, 71, 0.06);
|
||||
background: linear-gradient(90deg, transparent, rgba(4, 4, 4, 0.7), transparent);
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
@@ -69,7 +68,7 @@ defineProps({
|
||||
|
||||
.balance-list__line {
|
||||
width: 2rpx;
|
||||
height: 28rpx;
|
||||
height: 16rpx;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0 22rpx;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
switchable: { type: Boolean, default: false },
|
||||
onBack: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["select-store"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,16 +32,20 @@ const props = defineProps({
|
||||
>
|
||||
{{ title }}
|
||||
</text>
|
||||
<text v-if="subtitle" class="coin-header__subtitle">
|
||||
{{ subtitle }}
|
||||
</text>
|
||||
<view v-if="subtitle" class="coin-header__store" @click="switchable && emit('select-store')">
|
||||
<text class="coin-header__subtitle">{{ subtitle }}</text>
|
||||
<image v-if="switchable" class="coin-header__switch" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-switch.png" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</Header>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.coin-header__store { display: flex; align-items: center; max-width: 430rpx; }
|
||||
.coin-header__store .coin-header__subtitle { overflow: hidden; text-overflow: ellipsis; }
|
||||
.coin-header__switch { flex-shrink: 0; width: 28rpx; height: 28rpx; margin-left: 10rpx; }
|
||||
.coin-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const DEFAULT_PRODUCT_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-item.png";
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
|
||||
@@ -51,6 +51,9 @@ const productImage = computed(() => {
|
||||
height: 312rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx;
|
||||
background-image: url("https://static.shelingxingqiu.com/shootmini/static/coin/product-card-bg.png");
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
benefit: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 与会员卡共用剩余次数,接口缺失时显示占位。 -->
|
||||
<view class="benefit-card">
|
||||
<text class="benefit-card__badge">{{ benefit.remaining }}次</text>
|
||||
<image class="benefit-card__icon" :src="benefit.icon" mode="aspectFit" />
|
||||
<view class="benefit-card__content">
|
||||
<text class="benefit-card__title">{{ benefit.title }}</text>
|
||||
<text class="benefit-card__description">{{ benefit.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.benefit-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 176rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 28rpx 28rpx 28rpx 56rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(180deg, #080808, rgba(152, 124, 84, 0.4));
|
||||
}
|
||||
|
||||
// .benefit-card:last-child {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
|
||||
.benefit-card__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 64rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 16rpx 0 16rpx 0;
|
||||
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.benefit-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-right: 38rpx;
|
||||
}
|
||||
|
||||
.benefit-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.benefit-card__title {
|
||||
display: block;
|
||||
color: #fae6bc;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.benefit-card__description {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
default: "vip",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 业务 Tab 只切换内容,不接管系统导航。 -->
|
||||
<view class="section-tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="section-tabs__item"
|
||||
:class="{ 'section-tabs__item--active': active === tab.key }"
|
||||
hover-class="section-tabs__item--pressed"
|
||||
@click="emit('select', tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 40rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-tabs__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 326rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 34rpx;
|
||||
color: #999999;
|
||||
background: #000000;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.section-tabs__item--active {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #ffd19a, #a17636);
|
||||
}
|
||||
|
||||
.section-tabs__item--pressed {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
membership: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
benefits: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["introduction"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="vip-card" :class="{ 'vip-card--active': membership.active }">
|
||||
<!-- 切图仅含静态字效与徽章,次数、有效期和状态独立渲染。 -->
|
||||
<image class="vip-card__background" :src="membership.cardImage" mode="aspectFit" />
|
||||
<view class="vip-card__benefits">
|
||||
<view v-for="benefit in benefits" :key="benefit.key" class="vip-card__benefit">
|
||||
<text class="vip-card__label">{{ benefit.title }}</text>
|
||||
<view class="vip-card__count">
|
||||
<text class="vip-card__number">{{ membership.remaining[benefit.key] }}</text>
|
||||
<text class="vip-card__unit">次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="vip-card__status">
|
||||
{{ membership.active ? `有效期至${membership.expiresAt}` : "未开通" }}
|
||||
</text>
|
||||
<view class="vip-card__introduction" @click="emit('introduction')">
|
||||
<text>查看更多关于体验中心VIP的介绍 ></text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-card {
|
||||
position: relative;
|
||||
width: 750rpx;
|
||||
height: 288rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.vip-card__background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 750rpx;
|
||||
height: 288rpx;
|
||||
}
|
||||
|
||||
.vip-card__benefits {
|
||||
position: absolute;
|
||||
top: 124rpx;
|
||||
left: 70rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 400rpx;
|
||||
}
|
||||
|
||||
.vip-card__benefit {
|
||||
width: 120rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vip-card__label {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vip-card__count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
.vip-card__count::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16rpx;
|
||||
bottom: 4rpx;
|
||||
width: 88rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #ffd947;
|
||||
}
|
||||
|
||||
.vip-card__number {
|
||||
position: relative;
|
||||
font-size: 40rpx;
|
||||
line-height: 48rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vip-card__unit {
|
||||
position: relative;
|
||||
margin-left: 2rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vip-card__status {
|
||||
position: absolute;
|
||||
top: 212rpx;
|
||||
left: 496rpx;
|
||||
width: 232rpx;
|
||||
color: #888888;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vip-card--active .vip-card__status {
|
||||
color: #b47d42;
|
||||
}
|
||||
|
||||
.vip-card__introduction {
|
||||
position: absolute;
|
||||
left: 74rpx;
|
||||
bottom: 16rpx;
|
||||
padding: 6rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["open"]);
|
||||
const handleOpen = () => {
|
||||
if (!props.active) emit("open");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 当前门店已开通时仅展示状态,未开通时提供线下开通提醒。 -->
|
||||
<view class="vip-footer">
|
||||
<text class="vip-footer__price">特惠价格:9.9元/90天</text>
|
||||
<view class="vip-footer__status" @click="handleOpen">
|
||||
<image class="vip-footer__badge" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-badge.png" mode="aspectFit" />
|
||||
<text>{{ active ? "您已开通VIP" : "开通方式:请前往线下门店联系工作人员开通" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-footer {
|
||||
margin-top: 84rpx;
|
||||
padding: 0 24rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vip-footer__price {
|
||||
display: block;
|
||||
color: #fae6bc;
|
||||
font-size: 40rpx;
|
||||
line-height: 56rpx;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vip-footer__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 8rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 46rpx;
|
||||
color: #333333;
|
||||
background: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.vip-footer__badge {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 74rpx;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
// 金币快捷入口保持原有路由与图标配置。
|
||||
export const quickMenus = [
|
||||
{
|
||||
key: "rules",
|
||||
@@ -20,3 +21,30 @@ export const quickMenus = [
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
||||
},
|
||||
];
|
||||
|
||||
// 体验中心业务 Tab。
|
||||
export const storeSections = [
|
||||
{ key: "vip", label: "体验中心VIP" },
|
||||
{ key: "coin", label: "我的金币" },
|
||||
];
|
||||
|
||||
export const storeVipBenefits = [
|
||||
{
|
||||
key: "trial",
|
||||
title: "免费体验权益",
|
||||
description: "使用该权益可免费体验智能弓箭30分钟1次,加入官方社群还可以额外获赠1次。",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-trial.png",
|
||||
},
|
||||
{
|
||||
key: "discount",
|
||||
title: "特惠购买权益",
|
||||
description: "使用该权益可以19.9元的特惠价格购买30分钟体验服务1次。",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-discount.png",
|
||||
},
|
||||
{
|
||||
key: "drink",
|
||||
title: "到店赠饮权益",
|
||||
description: "使用该权益每次到店后可获得免费赠送的指定饮品一支。",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-drink.png",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
||||
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||
import RewardProductCard from "./components/RewardProductCard.vue";
|
||||
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
||||
import { quickMenus } from "./data";
|
||||
import { quickMenus, storeSections, storeVipBenefits } from "./data";
|
||||
import StoreSectionTabs from "./components/StoreSectionTabs.vue";
|
||||
import StoreVipCard from "./components/StoreVipCard.vue";
|
||||
import StoreBenefitCard from "./components/StoreBenefitCard.vue";
|
||||
import StoreVipFooter from "./components/StoreVipFooter.vue";
|
||||
import { getSelectedExperienceStore, rememberExperienceStore, loadStoreMembership } from "@/utils/storeVip";
|
||||
|
||||
const activeTab = ref("vip");
|
||||
const membership = ref(null);
|
||||
const vipLoading = ref(false);
|
||||
const vipError = ref("");
|
||||
const displayedBenefits = computed(() => storeVipBenefits.map((benefit) => ({
|
||||
...benefit, remaining: membership.value?.remaining[benefit.key] ?? "--",
|
||||
})));
|
||||
const loadMembership = async () => {
|
||||
if (!selectedStoreId.value || vipLoading.value) return;
|
||||
vipLoading.value = true;
|
||||
vipError.value = "";
|
||||
membership.value = null;
|
||||
try {
|
||||
const result = await loadStoreMembership(selectedStoreId.value);
|
||||
membership.value = result;
|
||||
if (result.name) selectedStoreName.value = result.name;
|
||||
rememberExperienceStore(selectedStoreId.value, selectedStoreName.value);
|
||||
} catch (error) {
|
||||
vipError.value = "会员信息加载失败,请点击重试或切换门店";
|
||||
console.error("加载门店会员失败", error);
|
||||
} finally {
|
||||
vipLoading.value = false;
|
||||
}
|
||||
};
|
||||
const selectStore = () => uni.redirectTo({ url: `/pages/coin/nearby-stores?tab=${activeTab.value}` });
|
||||
const selectTab = (tab) => {
|
||||
if (storeSections.some((item) => item.key === tab)) activeTab.value = tab;
|
||||
};
|
||||
const showVipIntroduction = () => uni.navigateTo({ url: "/pages/coin/vip-intro" });
|
||||
const vipOpeningVisible = ref(false);
|
||||
const showVipOpening = () => { vipOpeningVisible.value = true; };
|
||||
const closeVipOpening = () => { vipOpeningVisible.value = false; };
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const coinSummary = ref({
|
||||
@@ -19,8 +58,8 @@ const selectedStoreId = ref("");
|
||||
const selectedStoreName = ref("");
|
||||
const rewardProducts = ref([]);
|
||||
const productPage = ref(0);
|
||||
const productTotal = ref(0);
|
||||
const productLoading = ref(false);
|
||||
const productError = ref(false);
|
||||
const productNoMore = ref(false);
|
||||
|
||||
const loadCoinSummary = async () => {
|
||||
@@ -40,6 +79,7 @@ const loadProducts = async ({ reset = false } = {}) => {
|
||||
|
||||
const nextPage = reset ? 1 : productPage.value + 1;
|
||||
productLoading.value = true;
|
||||
productError.value = false;
|
||||
if (reset) productNoMore.value = false;
|
||||
|
||||
try {
|
||||
@@ -65,7 +105,6 @@ const loadProducts = async ({ reset = false } = {}) => {
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize =
|
||||
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||
productTotal.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
productNoMore.value = productPage.value >= pageCount;
|
||||
@@ -79,6 +118,7 @@ const loadProducts = async ({ reset = false } = {}) => {
|
||||
rewardProducts.value = [];
|
||||
productPage.value = 0;
|
||||
}
|
||||
productError.value = true;
|
||||
console.error("加载礼品列表失败", error);
|
||||
} finally {
|
||||
productLoading.value = false;
|
||||
@@ -115,7 +155,7 @@ const decodeRouteText = (value = "") => {
|
||||
const onMenuSelect = (menu) => {
|
||||
if (menu.key === "nearbyStores") {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
url: `/pages/coin/nearby-stores?tab=${activeTab.value}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -143,27 +183,69 @@ const toProductDetail = (product) => {
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const storeId = String(options.storeId || "");
|
||||
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||
const previous = getSelectedExperienceStore();
|
||||
const storeId = String(options.storeId || previous?.id || "");
|
||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
selectStore();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedStoreId.value = storeId;
|
||||
selectedStoreName.value = decodeRouteText(options.storeName);
|
||||
loadCoinSummary();
|
||||
selectedStoreName.value = options.storeId
|
||||
? decodeRouteText(options.storeName)
|
||||
: previous?.name || "";
|
||||
rememberExperienceStore(storeId, selectedStoreName.value);
|
||||
loadProducts({ reset: true });
|
||||
});
|
||||
|
||||
// 从介绍、商品等页面返回时刷新会员和余额。
|
||||
onShow(() => {
|
||||
if (!selectedStoreId.value) return;
|
||||
loadMembership();
|
||||
loadCoinSummary();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
|
||||
<!-- 标题固定,门店切换复用原有附近门店页面。 -->
|
||||
<Container :bgType="6" bgColor="#22222e" :isHome="true" @scrolltolower="activeTab === 'coin' && loadProducts()">
|
||||
<template #header>
|
||||
<CoinHeader title="我的金币" :subtitle="selectedStoreName" />
|
||||
<CoinHeader title="线下体验中心" :subtitle="selectedStoreName || '选择门店'" switchable @select-store="selectStore" />
|
||||
</template>
|
||||
<view class="coin-page">
|
||||
<StoreSectionTabs
|
||||
:tabs="storeSections"
|
||||
:active="activeTab"
|
||||
@select="selectTab"
|
||||
/>
|
||||
|
||||
<!-- 卡片与权益列表共用当前门店的会员数据。 -->
|
||||
<view v-if="activeTab === 'vip'" class="vip-section">
|
||||
<view v-if="vipLoading" class="page-status">会员信息加载中...</view>
|
||||
<view v-else-if="vipError" class="page-status" @click="loadMembership">{{ vipError }}</view>
|
||||
<StoreVipCard
|
||||
v-else-if="membership"
|
||||
:membership="membership"
|
||||
:benefits="storeVipBenefits"
|
||||
@introduction="showVipIntroduction"
|
||||
/>
|
||||
<view class="vip-section__content">
|
||||
<text class="vip-section__description">
|
||||
门店体验中心VIP是为热爱射箭运动的用户专属打造的VIP服务,开通后即可享受以下三大专属权益:
|
||||
</text>
|
||||
<view class="vip-section__benefits">
|
||||
<StoreBenefitCard
|
||||
v-for="benefit in displayedBenefits"
|
||||
:key="benefit.key"
|
||||
:benefit="benefit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<StoreVipFooter v-if="membership" :active="membership.active" @open="showVipOpening" />
|
||||
</view>
|
||||
|
||||
<!-- 保留原金币接口、分页与详情链路。 -->
|
||||
<view v-else class="coin-section">
|
||||
<CoinBalancePanel
|
||||
:cumulative="coinSummary.cumulative"
|
||||
:available="coinSummary.available"
|
||||
@@ -182,11 +264,23 @@ onLoad((options = {}) => {
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="productLoading" class="page-status">加载中...</view>
|
||||
<view v-else-if="productError" class="page-status" @click="loadProducts()">加载失败,点击重试</view>
|
||||
<view v-else-if="productNoMore" class="page-status">{{ rewardProducts.length ? '没有更多商品了~' : '暂无兑换商品~' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="vipOpeningVisible"
|
||||
content="请前往线下门店体验中心,联系工作人员开通VIP。"
|
||||
:show-cancel="false"
|
||||
confirm-text="我已知晓"
|
||||
:on-confirm="closeVipOpening"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.page-status { padding: 30rpx; color: #cccccc; text-align: center; font-size: 26rpx; }
|
||||
.coin-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
@@ -194,6 +288,30 @@ onLoad((options = {}) => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vip-section {
|
||||
padding-top: 30rpx;
|
||||
}
|
||||
|
||||
.vip-section__content {
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.vip-section__description {
|
||||
display: block;
|
||||
margin-top: 50rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.vip-section__benefits {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.coin-section {
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.reward-section {
|
||||
padding: 20rpx 28rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -6,6 +6,9 @@ import CoinHeader from "./components/CoinHeader.vue";
|
||||
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||
import StoreCard from "./components/StoreCard.vue";
|
||||
import { getNearbyStoresAPI } from "@/apis";
|
||||
import { rememberExperienceStore } from "@/utils/storeVip";
|
||||
|
||||
const activeTab = ref("vip");
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_STORE_IMAGE =
|
||||
@@ -113,12 +116,16 @@ const selectStore = (store) => {
|
||||
}
|
||||
|
||||
const storeName = encodeURIComponent(store?.name || "");
|
||||
rememberExperienceStore(storeId, store?.name || "");
|
||||
uni.redirectTo({
|
||||
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}`,
|
||||
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}&tab=${activeTab.value}`,
|
||||
});
|
||||
};
|
||||
|
||||
onLoad(locateAndLoadStores);
|
||||
onLoad((options = {}) => {
|
||||
activeTab.value = options.tab === "coin" ? "coin" : "vip";
|
||||
locateAndLoadStores();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import { getStoreVipIntroAPI } from "@/apis";
|
||||
|
||||
const content = ref("");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
const loadIntroduction = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const result = await getStoreVipIntroAPI();
|
||||
if (typeof result?.content !== "string") {
|
||||
throw new Error("体验中心 VIP 介绍响应格式异常");
|
||||
}
|
||||
// 保留服务端原文与换行,空白内容单独展示提示。
|
||||
content.value = result.content;
|
||||
} catch (error) {
|
||||
failed.value = true;
|
||||
console.error("加载体验中心 VIP 介绍失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadIntroduction);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" bgColor="#22222e" :isHome="true">
|
||||
<template #header><CoinHeader title="体验中心VIP介绍" /></template>
|
||||
<view class="vip-intro">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-else-if="failed" @click="loadIntroduction">加载失败,点击重试</text>
|
||||
<text v-else-if="content.trim()">{{ content }}</text>
|
||||
<text v-else>暂无介绍内容</text>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-intro { padding: 32rpx 28rpx; color: #ffffff; font-size: 28rpx; line-height: 44rpx; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -18,6 +18,16 @@ export function useDeviceBinding({
|
||||
});
|
||||
};
|
||||
|
||||
const showAlreadyBoundPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/device-already-bound",
|
||||
fail: (error) => {
|
||||
console.error("打开设备已绑定页失败", error);
|
||||
uni.showToast({ title: "设备已绑定其他账号,请解绑后再绑定", icon: "none" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleScan = () => {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
@@ -45,10 +55,8 @@ export function useDeviceBinding({
|
||||
const result = await bindDeviceAPIV2(token.value);
|
||||
confirmBindTip.value = false;
|
||||
if (result?.binded) {
|
||||
uni.showToast({
|
||||
title: "设备已绑定其他账号,请解绑后再绑定",
|
||||
icon: "none",
|
||||
});
|
||||
token.value = "";
|
||||
showAlreadyBoundPage();
|
||||
return;
|
||||
}
|
||||
const deviceId = String(result?.deviceId || "").trim();
|
||||
|
||||
@@ -11,8 +11,10 @@ export function useDeviceStatus({
|
||||
user,
|
||||
device,
|
||||
deviceStatus,
|
||||
deviceUsageDuration,
|
||||
online,
|
||||
updateDevice,
|
||||
updateDeviceUsageDuration,
|
||||
clearDevice,
|
||||
unbindDialogVisible,
|
||||
}) {
|
||||
@@ -41,14 +43,28 @@ export function useDeviceStatus({
|
||||
const batteryText = computed(() =>
|
||||
battery.value === null ? "暂无数据" : `${battery.value}%`
|
||||
);
|
||||
const normalizeDuration = (value) => {
|
||||
if (value == null || String(value).trim() === "") return null;
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
||||
};
|
||||
|
||||
const onlineDurationText = computed(() => {
|
||||
const rawDuration = deviceStatus.value.onlineDuration;
|
||||
if (rawDuration == null || String(rawDuration).trim() === "") return "--";
|
||||
const currentDeviceId = String(device.value.deviceId || "");
|
||||
const cachedDuration =
|
||||
String(deviceUsageDuration.value.deviceId || "") === currentDeviceId
|
||||
? normalizeDuration(deviceUsageDuration.value.seconds)
|
||||
: null;
|
||||
const durations = [
|
||||
normalizeDuration(deviceStatus.value.onlineDuration),
|
||||
normalizeDuration(deviceDetails.value.onlineDuration),
|
||||
cachedDuration,
|
||||
].filter((value) => value !== null);
|
||||
|
||||
const seconds = Number(rawDuration);
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "--";
|
||||
if (!durations.length) return "--";
|
||||
const seconds = Math.max(...durations);
|
||||
|
||||
// WS 推送秒数,页面按完整分钟展示累计使用时间。
|
||||
// 累计时长应单调递增,页面按完整分钟展示当前已知最大值。
|
||||
const totalMinutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
@@ -66,8 +82,7 @@ export function useDeviceStatus({
|
||||
const maskedDeviceId = computed(() => {
|
||||
const id = String(device.value.deviceId || "");
|
||||
if (!id) return "暂无设备编号";
|
||||
if (id.length <= 3) return id;
|
||||
return `${"*".repeat(Math.min(5, id.length - 3))}${id.slice(-3)}`;
|
||||
return "*****";
|
||||
});
|
||||
const deviceRows = computed(() => [
|
||||
{ label: "设备型号", value: deviceDetails.value.model || "射灵智能弓" },
|
||||
@@ -83,7 +98,7 @@ export function useDeviceStatus({
|
||||
|
||||
const refreshDeviceDetails = async () => {
|
||||
const deviceId = device.value.deviceId;
|
||||
if (!deviceId) return;
|
||||
if (!deviceId) return null;
|
||||
|
||||
const requestVersion = ++deviceDetailRequestVersion;
|
||||
try {
|
||||
@@ -95,13 +110,17 @@ export function useDeviceStatus({
|
||||
requestVersion !== deviceDetailRequestVersion ||
|
||||
device.value.deviceId !== deviceId
|
||||
) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
deviceDetails.value = {
|
||||
...deviceDetails.value,
|
||||
...detail,
|
||||
deviceModelName: detail.deviceModelName ?? "",
|
||||
latestVersion: detail.latestVersion ?? "",
|
||||
needUpdate: detail.needUpdate ?? false,
|
||||
forceUpdate: detail.forceUpdate ?? false,
|
||||
version: detail.version ?? "",
|
||||
bindTime: String(
|
||||
detail.bindTime ?? deviceDetails.value.bindTime ?? ""
|
||||
).trim(),
|
||||
@@ -109,9 +128,12 @@ export function useDeviceStatus({
|
||||
detail.qrCodeUrl ?? deviceDetails.value.qrCodeUrl ?? ""
|
||||
).trim(),
|
||||
};
|
||||
updateDeviceUsageDuration(detail.onlineDuration, deviceId);
|
||||
return detail;
|
||||
} catch (error) {
|
||||
// 实时刷新失败时保留当前页面数据,等待下次通知或页面重新显示。
|
||||
console.log("刷新设备详情失败", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,6 +148,10 @@ export function useDeviceStatus({
|
||||
let latestDevice = {
|
||||
...currentDevice,
|
||||
deviceModelName: "",
|
||||
latestVersion: "",
|
||||
needUpdate: false,
|
||||
forceUpdate: false,
|
||||
version: "",
|
||||
bindTime: "",
|
||||
qrCodeUrl: "",
|
||||
};
|
||||
@@ -158,6 +184,7 @@ export function useDeviceStatus({
|
||||
latestDevice.name ||
|
||||
"我的智能弓"
|
||||
);
|
||||
updateDeviceUsageDuration(latestDevice.onlineDuration, latestDevice.deviceId);
|
||||
return;
|
||||
}
|
||||
clearDevice();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import Container from "@/components/Container.vue";
|
||||
|
||||
// 结果页从设备页进入,返回时优先保留原页面状态。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
// 回到设备页后复用其扫码绑定流程。
|
||||
const retryScan = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success: () => uni.$emit("device-bind-retry-scan"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device?retryScan=1" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-already-bound-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="already-bound-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="already-bound-page">
|
||||
<view class="already-bound-scene">
|
||||
<view class="already-bound-hero-wrap">
|
||||
<view class="already-bound-hero-shadow"></view>
|
||||
<image
|
||||
class="already-bound-hero"
|
||||
src="/static/device-assets/device-already-bound-hero.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="already-bound-message">
|
||||
<text>该设备已经绑定了其他账号,</text>
|
||||
<text>如需绑定本账号,请先解除绑定。</text>
|
||||
</view>
|
||||
<view class="already-bound-retry" @click="$clickSound(retryScan)">
|
||||
<text>重新扫码</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-already-bound-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.already-bound-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 与现有绑定结果页保持相同的 375 宽画布尺寸。 */
|
||||
.already-bound-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.already-bound-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.already-bound-hero-wrap,
|
||||
.already-bound-message,
|
||||
.already-bound-retry {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.already-bound-hero-wrap {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.already-bound-hero-shadow {
|
||||
position: absolute;
|
||||
left: 22rpx;
|
||||
bottom: 0;
|
||||
width: 238rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.already-bound-hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.already-bound-message {
|
||||
top: 804rpx;
|
||||
left: 110rpx;
|
||||
display: flex;
|
||||
width: 530rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.already-bound-retry {
|
||||
top: 930rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,6 @@ import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import OtaModal from "@/components/OtaModal.vue";
|
||||
import {
|
||||
getHardwareBoxVersionAPI,
|
||||
laserAimAPI,
|
||||
updateDeviceAliasAPI,
|
||||
} from "@/apis";
|
||||
@@ -19,11 +18,11 @@ import {
|
||||
DEVICE_NAME_STORAGE_KEY,
|
||||
useDeviceStatus,
|
||||
} from "./composables/useDeviceStatus";
|
||||
import { useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||
import { getOtaInfoFromDetail, useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||
|
||||
const store = useStore();
|
||||
const { updateDevice, clearDevice } = store;
|
||||
const { user, device, deviceStatus, online } = storeToRefs(store);
|
||||
const { updateDevice, updateDeviceUsageDuration, clearDevice } = store;
|
||||
const { user, device, deviceStatus, deviceUsageDuration, online } = storeToRefs(store);
|
||||
const DEVICE_NAME_MAX_LENGTH = 10;
|
||||
|
||||
const formatBindingDate = (value) => {
|
||||
@@ -62,11 +61,8 @@ const showDeviceId = ref(false);
|
||||
const latestVersionDialogVisible = ref(false);
|
||||
const firmwareConfirmVisible = ref(false);
|
||||
const wifiRequiredVisible = ref(false);
|
||||
const otaNeedUpdate = ref(false);
|
||||
const firmwareActionPending = ref(false);
|
||||
const pendingOtaInfo = ref(null);
|
||||
let otaCheckPromise = null;
|
||||
let otaCheckRequestVersion = 0;
|
||||
|
||||
const {
|
||||
updating: otaUpdating,
|
||||
@@ -97,8 +93,10 @@ const {
|
||||
user,
|
||||
device,
|
||||
deviceStatus,
|
||||
deviceUsageDuration,
|
||||
online,
|
||||
updateDevice,
|
||||
updateDeviceUsageDuration,
|
||||
clearDevice,
|
||||
unbindDialogVisible,
|
||||
});
|
||||
@@ -111,6 +109,28 @@ const wifiStatusText = computed(() => {
|
||||
const bindingDateText = computed(() =>
|
||||
formatBindingDate(deviceDetails.value.bindTime)
|
||||
);
|
||||
const otaNeedUpdate = computed(() => {
|
||||
const info = getOtaInfoFromDetail(deviceDetails.value);
|
||||
return isDeviceOnline.value &&
|
||||
deviceDetails.value.deviceId === device.value.deviceId &&
|
||||
info.needUpdate && !!info.versionNumber;
|
||||
});
|
||||
|
||||
// 固件版本统一以大写 V 开头,避免接口返回格式不一致影响弹窗展示。
|
||||
const formatFirmwareVersion = (value) => {
|
||||
const version = String(value ?? "").trim();
|
||||
if (!version) return "";
|
||||
return /^v/i.test(version) ? `V${version.slice(1)}` : `V${version}`;
|
||||
};
|
||||
|
||||
const firmwareConfirmContent = computed(() => {
|
||||
const currentVersion = formatFirmwareVersion(pendingOtaInfo.value?.currentVersion);
|
||||
const latestVersion = formatFirmwareVersion(pendingOtaInfo.value?.versionNumber);
|
||||
if (currentVersion && latestVersion) {
|
||||
return `当前固件版本为 ${currentVersion},发现新固件版本 ${latestVersion},是否立即更新固件?`;
|
||||
}
|
||||
return latestVersion ? `发现新固件版本 ${latestVersion},是否立即更新固件?` : "是否立即更新固件?";
|
||||
});
|
||||
|
||||
const deviceIdText = computed(() =>
|
||||
showDeviceId.value && device.value.deviceName
|
||||
@@ -248,84 +268,20 @@ const joinWifi = () => {
|
||||
uni.navigateTo({ url: "/pages/device/ota-wifi" });
|
||||
};
|
||||
|
||||
const clearOtaState = () => {
|
||||
otaCheckRequestVersion += 1;
|
||||
otaCheckPromise = null;
|
||||
otaNeedUpdate.value = false;
|
||||
};
|
||||
|
||||
const loadOtaVersionInfo = async () => {
|
||||
if (otaCheckPromise) return otaCheckPromise;
|
||||
|
||||
const deviceId = device.value.deviceId;
|
||||
if (!deviceId || !isDeviceOnline.value) return null;
|
||||
|
||||
const requestVersion = ++otaCheckRequestVersion;
|
||||
const request = getHardwareBoxVersionAPI()
|
||||
.then((versionInfo) => {
|
||||
if (
|
||||
requestVersion !== otaCheckRequestVersion ||
|
||||
device.value.deviceId !== deviceId ||
|
||||
!isDeviceOnline.value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!versionInfo || typeof versionInfo !== "object") {
|
||||
throw new Error("固件版本信息为空");
|
||||
}
|
||||
|
||||
const needUpdate =
|
||||
versionInfo.needUpdate === true || Number(versionInfo.needUpdate) === 1;
|
||||
otaNeedUpdate.value = needUpdate;
|
||||
return {
|
||||
versionNumber: versionInfo.versionNumber || "",
|
||||
resourceUrl: versionInfo.resourceUrl || "",
|
||||
needUpdate,
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
if (otaCheckPromise === request) {
|
||||
otaCheckPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
otaCheckPromise = request;
|
||||
return request;
|
||||
};
|
||||
|
||||
const refreshOtaUpdateState = async () => {
|
||||
otaNeedUpdate.value = false;
|
||||
if (!device.value.deviceId || !isDeviceOnline.value) {
|
||||
clearOtaState();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadOtaVersionInfo();
|
||||
} catch (error) {
|
||||
clearOtaState();
|
||||
console.log("检查固件更新失败", error);
|
||||
}
|
||||
const closeFirmwareConfirm = () => {
|
||||
firmwareConfirmVisible.value = false;
|
||||
};
|
||||
|
||||
const closeLatestVersionDialog = () => {
|
||||
latestVersionDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const closeFirmwareConfirm = () => {
|
||||
firmwareConfirmVisible.value = false;
|
||||
};
|
||||
|
||||
const runFirmwareUpdate = () => {
|
||||
const versionInfo = pendingOtaInfo.value;
|
||||
if (!versionInfo) return;
|
||||
const otaInfo = pendingOtaInfo.value;
|
||||
if (!otaInfo || otaInfo.deviceId !== device.value.deviceId) return;
|
||||
void startOtaUpdate({
|
||||
versionNumber: versionInfo.versionNumber,
|
||||
resourceUrl: versionInfo.resourceUrl,
|
||||
onSuccess: () => {
|
||||
otaNeedUpdate.value = false;
|
||||
},
|
||||
versionNumber: otaInfo.versionNumber,
|
||||
onSuccess: () => void refreshDeviceDetails(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -343,42 +299,43 @@ const confirmFirmwareUpdate = () => {
|
||||
};
|
||||
|
||||
const goWifiForFirmwareUpdate = () => {
|
||||
const versionInfo = pendingOtaInfo.value;
|
||||
if (!versionInfo) return;
|
||||
const otaInfo = pendingOtaInfo.value;
|
||||
if (!otaInfo || otaInfo.deviceId !== device.value.deviceId) return;
|
||||
wifiRequiredVisible.value = false;
|
||||
const query = [
|
||||
"source=firmware-update",
|
||||
`versionNumber=${encodeURIComponent(versionInfo.versionNumber)}`,
|
||||
`resourceUrl=${encodeURIComponent(versionInfo.resourceUrl)}`,
|
||||
].join("&");
|
||||
uni.navigateTo({ url: `/pages/device/ota-wifi?${query}` });
|
||||
const versionNumber = encodeURIComponent(otaInfo.versionNumber);
|
||||
uni.navigateTo({ url: `/pages/device/ota-wifi?source=firmware-update&versionNumber=${versionNumber}` });
|
||||
};
|
||||
|
||||
const handleOtaResultClose = () => {
|
||||
closeOtaResult();
|
||||
void refreshOtaUpdateState();
|
||||
void refreshDeviceDetails();
|
||||
};
|
||||
|
||||
const goFirmwareUpdate = async () => {
|
||||
if (firmwareActionPending.value) return;
|
||||
if (firmwareActionPending.value || otaUpdating.value) return;
|
||||
if (!isDeviceOnline.value) {
|
||||
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
firmwareActionPending.value = true;
|
||||
const deviceId = device.value.deviceId;
|
||||
try {
|
||||
const versionInfo = await loadOtaVersionInfo();
|
||||
if (!versionInfo) return;
|
||||
if (!versionInfo.needUpdate) {
|
||||
const detail = await refreshDeviceDetails();
|
||||
if (!detail || device.value.deviceId !== deviceId) {
|
||||
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
const otaInfo = getOtaInfoFromDetail(detail);
|
||||
if (!otaInfo.needUpdate) {
|
||||
latestVersionDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingOtaInfo.value = versionInfo;
|
||||
if (!otaInfo.versionNumber) {
|
||||
uni.showToast({ title: "缺少固件目标版本,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
pendingOtaInfo.value = { ...otaInfo, deviceId };
|
||||
firmwareConfirmVisible.value = true;
|
||||
} catch (error) {
|
||||
uni.showToast({ title: "获取更新版本失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
firmwareActionPending.value = false;
|
||||
}
|
||||
@@ -427,15 +384,20 @@ watch(
|
||||
(isOnline, wasOnline) => {
|
||||
if (isOnline && !wasOnline) {
|
||||
void refreshDeviceDetails();
|
||||
void refreshOtaUpdateState();
|
||||
return;
|
||||
}
|
||||
if (!isOnline) {
|
||||
clearOtaState();
|
||||
} else if (!isOnline) {
|
||||
firmwareConfirmVisible.value = false;
|
||||
wifiRequiredVisible.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => device.value.deviceId, () => {
|
||||
pendingOtaInfo.value = null;
|
||||
latestVersionDialogVisible.value = false;
|
||||
firmwareConfirmVisible.value = false;
|
||||
wifiRequiredVisible.value = false;
|
||||
});
|
||||
|
||||
onLoad((options = {}) => {
|
||||
retryScanOnShow.value = options.retryScan === "1";
|
||||
});
|
||||
@@ -451,7 +413,6 @@ onUnmounted(() => {
|
||||
onShow(async () => {
|
||||
calibration.value = uni.getStorageSync("calibration");
|
||||
await syncDeviceBinding();
|
||||
void refreshOtaUpdateState();
|
||||
if (retryScanOnShow.value) {
|
||||
retryScanOnShow.value = false;
|
||||
handleScan();
|
||||
@@ -486,12 +447,12 @@ onShow(async () => {
|
||||
<text class="highlight-text">射灵智能弓箭</text>
|
||||
<text>设备上的二维码</text>
|
||||
</view>
|
||||
<view class="benefit-copy">
|
||||
<!-- <view class="benefit-copy">
|
||||
<text>新设备首次绑定账号可获赠</text>
|
||||
<text class="highlight-text">6个月射灵会员礼包</text>
|
||||
<text>,</text>
|
||||
<text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<button class="help-link" hover-class="none" @click="showTip = true">
|
||||
<text>没找到二维码或遇到问题></text>
|
||||
</button>
|
||||
@@ -519,7 +480,7 @@ onShow(async () => {
|
||||
class="device-bow"
|
||||
:class="{ 'device-bow--online': isDeviceOnline }"
|
||||
:src="isDeviceOnline
|
||||
? 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-bow--online.png'
|
||||
? 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-bow.png'
|
||||
: 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-bow.png'"
|
||||
:mode="isDeviceOnline ? 'aspectFit' : 'widthFix'"
|
||||
/>
|
||||
@@ -583,10 +544,7 @@ onShow(async () => {
|
||||
</view>
|
||||
<view class="action-label-wrap">
|
||||
<text>固件更新</text>
|
||||
<text
|
||||
v-if="otaNeedUpdate"
|
||||
class="action-badge action-badge--new"
|
||||
>New</text>
|
||||
<text v-if="otaNeedUpdate" class="action-badge action-badge--new">New</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-item" @click="joinWifi">
|
||||
@@ -655,7 +613,11 @@ onShow(async () => {
|
||||
</view>
|
||||
</ModalDialog>
|
||||
|
||||
<ScreenHint :show="confirmBindTip" :onClose="closeConfirmBindTip">
|
||||
<ScreenHint
|
||||
:show="confirmBindTip"
|
||||
:onClose="closeConfirmBindTip"
|
||||
contentHeight="360rpx"
|
||||
>
|
||||
<view class="confirm-bind">
|
||||
<text>智能弓箭和系统账号需一一对应,你确定要将当前登录用户账号绑定这把弓箭吗?绑定后不可随意更换。</text>
|
||||
<view class="confirm-actions">
|
||||
@@ -693,9 +655,10 @@ onShow(async () => {
|
||||
<ModalDialog
|
||||
:show="firmwareConfirmVisible"
|
||||
title="固件更新"
|
||||
content="是否现在进行固件更新?"
|
||||
cancelText="取消"
|
||||
confirmText="确定"
|
||||
:content="firmwareConfirmContent"
|
||||
cancelText="暂不更新"
|
||||
confirmText="立即更新"
|
||||
:showCancel="!pendingOtaInfo?.forceUpdate"
|
||||
:onCancel="closeFirmwareConfirm"
|
||||
:onConfirm="confirmFirmwareUpdate"
|
||||
></ModalDialog>
|
||||
@@ -903,7 +866,7 @@ onShow(async () => {
|
||||
}
|
||||
|
||||
.help-link {
|
||||
margin-top: 72rpx;
|
||||
margin-top: 36rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
@@ -911,8 +874,8 @@ onShow(async () => {
|
||||
|
||||
.product-link {
|
||||
position: fixed;
|
||||
bottom: 104rpx;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 104rpx);
|
||||
bottom: 208rpx;
|
||||
/* bottom: calc(env(safe-area-inset-bottom) + 104rpx); */
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
@@ -971,7 +934,7 @@ onShow(async () => {
|
||||
|
||||
.device-bow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: -2rpx;
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
width: 562rpx;
|
||||
@@ -1319,6 +1282,10 @@ onShow(async () => {
|
||||
}
|
||||
|
||||
.confirm-bind {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-top: 100rpx;
|
||||
transform: translateY(24rpx);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 26rpx;
|
||||
line-height: 42rpx;
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import OtaModal from "@/components/OtaModal.vue";
|
||||
import useStore from "@/store";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import {
|
||||
connectDeviceWifiAPI,
|
||||
getHardwareBoxVersionAPI,
|
||||
getDeviceDetailAPI,
|
||||
getDeviceWifiStatusAPI,
|
||||
} from "@/apis";
|
||||
import { useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||
import { getOtaInfoFromDetail, useOtaUpdate } from "@/composables/useOtaUpdate";
|
||||
|
||||
const STATES = {
|
||||
SCANNING: "SCANNING",
|
||||
LIST: "LIST",
|
||||
CONNECTING: "CONNECTING",
|
||||
CONNECTED: "CONNECTED",
|
||||
LOCATION_REQUIRED: "LOCATION_REQUIRED",
|
||||
};
|
||||
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
const { device, deviceStatus } = storeToRefs(useStore());
|
||||
const currentState = ref(STATES.SCANNING);
|
||||
const connectedWifi = ref(null);
|
||||
const connectedWifiName = ref("");
|
||||
const isDeviceOnWifi = computed(
|
||||
() => deviceStatus.value.online === true && deviceStatus.value.netType === "wifi"
|
||||
);
|
||||
const wifiList = ref([]);
|
||||
const showWifiBanner = ref(false);
|
||||
|
||||
@@ -30,25 +39,31 @@ const connectMode = ref("secure"); // secure | open | manual
|
||||
const connectError = ref("");
|
||||
const connectStatusText = ref("");
|
||||
const isSubmittingWifi = ref(false);
|
||||
const wifiSuccessVisible = ref(false);
|
||||
const keyboardHeight = ref(0);
|
||||
// 控制密码输入框是否显示明文
|
||||
const showPassword = ref(false);
|
||||
// 刷新防抖标志:扫描进行中为 true,禁止重复点击;扫描结束(成功/失败)后重置为 false。
|
||||
const isRefreshing = ref(false);
|
||||
const fromFirmwareUpdate = ref(false);
|
||||
const routeOtaInfo = ref({
|
||||
versionNumber: "",
|
||||
resourceUrl: "",
|
||||
});
|
||||
const routeOtaVersionNumber = ref("");
|
||||
const countdownVisible = ref(false);
|
||||
const countdownSeconds = ref(3);
|
||||
const firmwareMessageVisible = ref(false);
|
||||
const firmwareMessage = ref("");
|
||||
let countdownTimer = null;
|
||||
let wifiConnectRequestId = 0;
|
||||
let wifiStatusRequestId = 0;
|
||||
const WIFI_CONNECT_FAILED_TEXT = "连接失败,请检查WiFi密码或WiFi状态";
|
||||
// 控制授权拒绝弹窗显示/隐藏
|
||||
const wifiAuthDeniedVisible = ref(false);
|
||||
// 与 Container 的可滚动区域保持一致,底部说明在长短屏上都能贴近安全区。
|
||||
const windowInfo = uni.getWindowInfo();
|
||||
const contentMinHeight = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(windowInfo.windowHeight - capsuleHeight - 50) *
|
||||
(750 / windowInfo.windowWidth)
|
||||
)
|
||||
);
|
||||
|
||||
const {
|
||||
updating: otaUpdating,
|
||||
@@ -73,20 +88,29 @@ const isWifiPermissionDenied = (err) => {
|
||||
return /auth den|authorize|permission denied|user denied|scope\.userLocation/i.test(errMsg);
|
||||
};
|
||||
|
||||
// 显示授权拒绝弹窗,通过 ScreenHint 组件呈现。
|
||||
const showWifiPermissionDeniedModal = () => {
|
||||
wifiAuthDeniedVisible.value = true;
|
||||
// Android 定位不可用时展示蓝湖中的独立引导状态。
|
||||
const showLocationPermission = () => {
|
||||
currentState.value = STATES.LOCATION_REQUIRED;
|
||||
isRefreshing.value = false;
|
||||
};
|
||||
|
||||
// 用户点击「重新授权」时,打开小程序权限设置页,引导用户开启位置权限后重新扫描。
|
||||
// 已拒绝授权时进入小程序设置;首次授权则重新扫描以触发系统权限流程。
|
||||
const handleReauthorize = () => {
|
||||
wifiAuthDeniedVisible.value = false;
|
||||
uni.openSetting({
|
||||
success(res) {
|
||||
// 若用户在设置页开启了位置权限,则重新启动 WiFi 扫描
|
||||
if (res.authSetting["scope.userLocation"]) {
|
||||
startScanning();
|
||||
if (uni.getSystemSetting().locationEnabled === false) {
|
||||
uni.showToast({ title: "请先开启手机定位服务", icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.getSetting({
|
||||
success(res) {
|
||||
if (res.authSetting["scope.userLocation"] !== false) {
|
||||
startScanning();
|
||||
return;
|
||||
}
|
||||
uni.openSetting({
|
||||
success(setting) {
|
||||
if (setting.authSetting["scope.userLocation"]) startScanning();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,7 +120,7 @@ const applyIOSConnectedWifi = (wifi) => {
|
||||
const ssid = wifi?.SSID;
|
||||
if (!ssid) {
|
||||
wifiList.value = [];
|
||||
currentState.value = STATES.LIST;
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
isRefreshing.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +133,7 @@ const applyIOSConnectedWifi = (wifi) => {
|
||||
fromCurrentWifi: true,
|
||||
},
|
||||
];
|
||||
currentState.value = STATES.LIST;
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
isRefreshing.value = false;
|
||||
};
|
||||
|
||||
@@ -127,7 +151,7 @@ const syncIOSConnectedWifi = () => {
|
||||
err,
|
||||
});
|
||||
wifiList.value = [];
|
||||
currentState.value = STATES.LIST;
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
isRefreshing.value = false;
|
||||
},
|
||||
});
|
||||
@@ -147,7 +171,7 @@ const startIOSScanning = () => {
|
||||
err,
|
||||
});
|
||||
if (err.errCode === 12005) showWifiBanner.value = true;
|
||||
currentState.value = STATES.LIST;
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
isRefreshing.value = false;
|
||||
},
|
||||
});
|
||||
@@ -168,6 +192,11 @@ const startScanning = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (uni.getSystemSetting().locationEnabled === false) {
|
||||
showLocationPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
wx.startWifi({
|
||||
success(res) {
|
||||
console.log("[OTA WiFi] wx.startWifi success:", res);
|
||||
@@ -181,7 +210,11 @@ const startScanning = () => {
|
||||
errMsg: err?.errMsg,
|
||||
err,
|
||||
});
|
||||
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
|
||||
if (isWifiPermissionDenied(err)) {
|
||||
showLocationPermission();
|
||||
} else {
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
}
|
||||
if (err.errCode === 12005) showWifiBanner.value = true;
|
||||
isRefreshing.value = false;
|
||||
},
|
||||
@@ -193,13 +226,18 @@ const startScanning = () => {
|
||||
errMsg: err?.errMsg,
|
||||
err,
|
||||
});
|
||||
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
|
||||
if (isWifiPermissionDenied(err)) {
|
||||
showLocationPermission();
|
||||
} else {
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
}
|
||||
if (err.errCode === 12005) showWifiBanner.value = true;
|
||||
isRefreshing.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
wx.onGetWifiList((res) => {
|
||||
if (currentState.value === STATES.LOCATION_REQUIRED) return;
|
||||
const realWifiList = res.wifiList || [];
|
||||
if (realWifiList.length) {
|
||||
console.log("[OTA WiFi] wx.onGetWifiList received wifiList:", realWifiList);
|
||||
@@ -207,7 +245,7 @@ const startScanning = () => {
|
||||
console.warn("[OTA WiFi] wx.onGetWifiList received empty wifiList:", res);
|
||||
}
|
||||
wifiList.value = realWifiList;
|
||||
currentState.value = STATES.LIST;
|
||||
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
|
||||
isRefreshing.value = false;
|
||||
});
|
||||
};
|
||||
@@ -256,12 +294,31 @@ const joinDisabled = computed(() => {
|
||||
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`;
|
||||
return `${itemCount * 108}rpx`;
|
||||
});
|
||||
|
||||
// 展示条件只取 WebSocket 设备状态;接口仅提供名称,过期请求不得覆盖新状态。
|
||||
const refreshConnectedWifiName = async () => {
|
||||
const requestId = ++wifiStatusRequestId;
|
||||
if (!isDeviceOnWifi.value) {
|
||||
connectedWifiName.value = "";
|
||||
return;
|
||||
}
|
||||
connectedWifiName.value = "";
|
||||
try {
|
||||
const status = await getDeviceWifiStatusAPI();
|
||||
if (requestId !== wifiStatusRequestId || !isDeviceOnWifi.value) return;
|
||||
connectedWifiName.value = String(status?.ssid ?? "").trim();
|
||||
} catch (error) {
|
||||
if (requestId === wifiStatusRequestId) connectedWifiName.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
watch(isDeviceOnWifi, () => {
|
||||
void refreshConnectedWifiName();
|
||||
});
|
||||
|
||||
// 取消当前 WiFi 连接请求的页面等待状态,并忽略可能迟到的响应。
|
||||
@@ -308,9 +365,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
|
||||
secure: connectMode.value === "secure" || !!password,
|
||||
};
|
||||
connectError.value = "";
|
||||
currentState.value = STATES.CONNECTED;
|
||||
void refreshConnectedWifiName();
|
||||
if (fromFirmwareUpdate.value) {
|
||||
currentState.value = STATES.CONNECTED;
|
||||
openFirmwareCountdown();
|
||||
} else {
|
||||
// 保留配网输入界面,避免成功提示出现前先露出 WiFi 列表。
|
||||
wifiSuccessVisible.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === wifiConnectRequestId) {
|
||||
@@ -325,6 +386,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 普通配网成功后,确认并收起输入界面,停留在当前 WiFi 页面。
|
||||
const confirmWifiSuccess = () => {
|
||||
if (!wifiSuccessVisible.value) return;
|
||||
wifiSuccessVisible.value = false;
|
||||
currentState.value = STATES.CONNECTED;
|
||||
};
|
||||
|
||||
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
|
||||
const joinNetwork = () => {
|
||||
if (joinDisabled.value) return;
|
||||
@@ -335,37 +403,38 @@ const joinNetwork = () => {
|
||||
submitDeviceWifiConfig({ ssid, password });
|
||||
};
|
||||
|
||||
// 获取 OTA 更新版本信息,正常使用入口传参,缺失时再请求后端兜底。
|
||||
const getOtaVersionInfo = async () => {
|
||||
if (routeOtaInfo.value.versionNumber && routeOtaInfo.value.resourceUrl) {
|
||||
return {
|
||||
needUpdate: true,
|
||||
versionNumber: routeOtaInfo.value.versionNumber,
|
||||
resourceUrl: routeOtaInfo.value.resourceUrl,
|
||||
};
|
||||
}
|
||||
return getHardwareBoxVersionAPI();
|
||||
};
|
||||
|
||||
const startFirmwareUpdate = async () => {
|
||||
if (!fromFirmwareUpdate.value || !connectedWifi.value || otaUpdating.value) return;
|
||||
const deviceId = device.value.deviceId;
|
||||
const wifi = connectedWifi.value;
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const versionInfo = await getOtaVersionInfo();
|
||||
if (!versionInfo?.needUpdate) {
|
||||
const response = await getDeviceDetailAPI(deviceId);
|
||||
const detail = response?.detail || response?.data?.detail;
|
||||
if (!detail || device.value.deviceId !== deviceId) throw new Error("设备详情已失效");
|
||||
const otaInfo = getOtaInfoFromDetail(detail);
|
||||
if (!otaInfo.needUpdate) {
|
||||
firmwareMessage.value = "当前已是最新版本";
|
||||
firmwareMessageVisible.value = true;
|
||||
return;
|
||||
}
|
||||
await startOtaUpdate({
|
||||
versionNumber: versionInfo.versionNumber,
|
||||
wifiSsid: connectedWifi.value.SSID,
|
||||
wifiPassword: connectedWifi.value.password || "",
|
||||
resourceUrl: versionInfo.resourceUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
firmwareMessage.value = "获取更新版本失败,请重试";
|
||||
if (!otaInfo.versionNumber) {
|
||||
firmwareMessage.value = "缺少固件目标版本,请返回后重试";
|
||||
firmwareMessageVisible.value = true;
|
||||
return;
|
||||
}
|
||||
// WiFi 连接可能耗时较长,以更新前的最新设备详情为准。
|
||||
routeOtaVersionNumber.value = otaInfo.versionNumber;
|
||||
} catch (error) {
|
||||
firmwareMessage.value = "获取设备详情失败,请返回后重试";
|
||||
firmwareMessageVisible.value = true;
|
||||
return;
|
||||
}
|
||||
await startOtaUpdate({
|
||||
versionNumber: routeOtaVersionNumber.value,
|
||||
wifiSsid: wifi.SSID,
|
||||
wifiPassword: wifi.password || "",
|
||||
});
|
||||
};
|
||||
|
||||
const clearFirmwareCountdown = () => {
|
||||
@@ -388,7 +457,9 @@ const openFirmwareCountdown = () => {
|
||||
|
||||
// 监听系统输入法高度,用于让底部弹窗避开键盘遮挡。
|
||||
const handleKeyboardHeightChange = (res) => {
|
||||
keyboardHeight.value = res?.height || 0;
|
||||
keyboardHeight.value = Math.round(
|
||||
(res?.height || 0) * (750 / windowInfo.windowWidth)
|
||||
);
|
||||
};
|
||||
|
||||
// 切换密码显示状态前先收起键盘,避免安全键盘和普通输入法切换时复用旧高度产生黑条。
|
||||
@@ -404,10 +475,7 @@ const togglePasswordVisibility = () => {
|
||||
// 页面加载时识别普通 WiFi 入口和固件更新入口。
|
||||
onLoad((options = {}) => {
|
||||
fromFirmwareUpdate.value = options.source === "firmware-update";
|
||||
routeOtaInfo.value = {
|
||||
versionNumber: decodeURIComponent(options.versionNumber || ""),
|
||||
resourceUrl: decodeURIComponent(options.resourceUrl || ""),
|
||||
};
|
||||
routeOtaVersionNumber.value = decodeURIComponent(options.versionNumber || "");
|
||||
});
|
||||
|
||||
// 页面挂载后启动 WiFi 扫描流程。
|
||||
@@ -420,12 +488,24 @@ onMounted(() => {
|
||||
|
||||
// iOS 从系统 WiFi 设置返回小程序后,同步当前手机连接的 WiFi。
|
||||
onShow(() => {
|
||||
void refreshConnectedWifiName();
|
||||
if (isIOS && currentState.value === STATES.LIST) {
|
||||
syncIOSConnectedWifi();
|
||||
} else if (
|
||||
!isIOS &&
|
||||
currentState.value === STATES.LOCATION_REQUIRED &&
|
||||
uni.getSystemSetting().locationEnabled !== false
|
||||
) {
|
||||
uni.getSetting({
|
||||
success(res) {
|
||||
if (res.authSetting["scope.userLocation"] !== false) startScanning();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
wifiStatusRequestId += 1;
|
||||
if (typeof uni.offKeyboardHeightChange === "function") {
|
||||
uni.offKeyboardHeightChange(handleKeyboardHeightChange);
|
||||
}
|
||||
@@ -436,79 +516,93 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="连接无线网络">
|
||||
<Container :scroll="false">
|
||||
<!-- 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"
|
||||
:style="{ height: contentMinHeight + 'rpx' }"
|
||||
>
|
||||
<!-- Hero: wifi1.png 单图(已内含吉祥物) -->
|
||||
<view class="hero-area">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/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="https://static.shelingxingqiu.com/shootmini/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="https://static.shelingxingqiu.com/shootmini/static/sicon/pwd.png"
|
||||
class="hero-image"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/ota/wifi1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image class="signal-icon" src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="page-title">智能弓箭连接无线网络</text>
|
||||
<text class="page-subtitle">提醒:本操作将为智能弓箭连接WIFI网络,不会影响手机自身连接的WIFI网络。</text>
|
||||
|
||||
<view class="network-sections">
|
||||
<!-- 仅设备 WebSocket 状态为 WiFi 时显示;此卡片不触发密码输入。 -->
|
||||
<view v-if="isDeviceOnWifi && connectedWifiName" class="network-section connected-section">
|
||||
<text class="section-label">已连接</text>
|
||||
<view class="connected-wifi-card">
|
||||
<text class="wifi-ssid">{{ connectedWifiName }}</text>
|
||||
<image
|
||||
class="check-icon"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/check.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 网络列表标题 -->
|
||||
<view class="network-section">
|
||||
<view class="section-label-row">
|
||||
<text class="section-label">网络</text>
|
||||
<text class="section-label">选择网络</text>
|
||||
<image
|
||||
class="refresh-icon"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/refresh.png"
|
||||
mode="aspectFit"
|
||||
:style="{ width: '34rpx', height: '34rpx', marginLeft: '8rpx', opacity: isRefreshing ? 0.3 : 0.7 }"
|
||||
:style="{ opacity: isRefreshing ? 0.3 : 0.6 }"
|
||||
@click="startScanning"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 网络列表卡片 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="wifi-list-card"
|
||||
:class="{ 'wifi-list-scroll': currentState !== 'SCANNING' }"
|
||||
:style="{ height: wifiListScrollHeight }"
|
||||
<block v-if="isIOS">
|
||||
<view
|
||||
v-if="wifiList.length"
|
||||
class="ios-network-card current-network-card"
|
||||
@click="selectWifi(wifiList[0])"
|
||||
>
|
||||
<block v-if="currentState === 'SCANNING'">
|
||||
<view class="wifi-item" @click="selectOther">
|
||||
<text class="wifi-ssid other-text">其他...</text>
|
||||
<view class="network-copy">
|
||||
<text class="wifi-ssid">{{ wifiList[0].SSID }}</text>
|
||||
<text class="network-desc">手机当前连接的网络</text>
|
||||
</view>
|
||||
<image
|
||||
class="signal-icon"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="ios-network-card manual-network-card" @click="selectOther">
|
||||
<view class="network-copy">
|
||||
<text class="wifi-ssid">手动连接其他网络</text>
|
||||
<text class="network-desc">由于权限限制,无法获取附近网络列表,如需连接其他网络请手动输入网络名称与密码进行连接。</text>
|
||||
</view>
|
||||
<image
|
||||
class="signal-icon"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
|
||||
<scroll-view
|
||||
v-else
|
||||
scroll-y
|
||||
class="android-list-card"
|
||||
:style="{ maxHeight: wifiListScrollHeight }"
|
||||
>
|
||||
<view
|
||||
v-for="(wifi, idx) in wifiList"
|
||||
v-for="wifi in wifiList"
|
||||
:key="wifi.SSID"
|
||||
class="wifi-item"
|
||||
class="android-network-item"
|
||||
@click="selectWifi(wifi)"
|
||||
>
|
||||
<text class="wifi-ssid">{{ wifi.SSID }}</text>
|
||||
@@ -519,20 +613,46 @@ onUnmounted(() => {
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/pwd.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image class="signal-icon" src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png" mode="aspectFit" />
|
||||
<image
|
||||
class="signal-icon"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/sicon/wifi.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="wifi-item" @click="selectOther">
|
||||
<text class="wifi-ssid other-text">其他...</text>
|
||||
<view class="android-network-item manual-network-item" @click="selectOther">
|
||||
<text class="wifi-ssid">手动连接其他网络</text>
|
||||
</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="wifi-footer">智能弓箭目前仅支持保存一条网络信息,若连接新的网络将删除上一条网络信息。</text>
|
||||
</view>
|
||||
|
||||
<!-- Android 定位权限不足时的整页引导状态 -->
|
||||
<view
|
||||
v-if="currentState === 'LOCATION_REQUIRED'"
|
||||
class="location-page"
|
||||
:style="{ minHeight: contentMinHeight + 'rpx' }"
|
||||
>
|
||||
<view class="location-image-wrap">
|
||||
<image
|
||||
class="location-image"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/wifi-location.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="location-shadow"></view>
|
||||
</view>
|
||||
<text class="location-desc">请授权允许获取定位信息,并开启定位功能,以查找附近可用无线网络。</text>
|
||||
<view class="location-button" @click="handleReauthorize">
|
||||
<text>授权获取定位信息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- CONNECTING 底部弹窗 -->
|
||||
<view v-if="currentState === 'CONNECTING'" class="sheet-mask">
|
||||
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'px' }" @click.stop="">
|
||||
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'rpx' }" @click.stop="">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
|
||||
<view class="sheet-inner">
|
||||
|
||||
@@ -646,20 +766,13 @@ onUnmounted(() => {
|
||||
</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>
|
||||
<ModalDialog
|
||||
:show="wifiSuccessVisible"
|
||||
content="WIFI连接成功"
|
||||
confirmText="确定"
|
||||
:showCancel="false"
|
||||
:onConfirm="confirmWifiSuccess"
|
||||
></ModalDialog>
|
||||
|
||||
<ModalDialog
|
||||
:show="countdownVisible"
|
||||
@@ -704,45 +817,6 @@ onUnmounted(() => {
|
||||
</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);
|
||||
@@ -756,200 +830,239 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.wifi-page {
|
||||
padding: 0 67rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 40rpx 54rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40rpx 0 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.hero-image {
|
||||
width: 164rpx;
|
||||
height: 152rpx;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: rgba(91, 196, 255, 1);
|
||||
display: block;
|
||||
color: #5fadff;
|
||||
font-size: 36rpx;
|
||||
line-height: 50rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.connected-page-title {
|
||||
color: #4DD9C8;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.page-subtitle {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
display: block;
|
||||
align-self: center;
|
||||
width: 552rpx;
|
||||
max-width: 100%;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
.fail-title {
|
||||
color: rgba(255, 100, 100, 1);
|
||||
.network-sections {
|
||||
margin-top: 60rpx;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ios-guide {
|
||||
background-color: rgba(95, 173, 255, 0.12);
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.network-section:last-child {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ios-guide-text {
|
||||
color: rgba(95, 173, 255, 1);
|
||||
.connected-section {
|
||||
margin-bottom: 60rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-label {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.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 {
|
||||
justify-content: space-between;
|
||||
height: 36rpx;
|
||||
margin: 0 40rpx 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.connected-wifi-card {
|
||||
background-color: rgba(35, 40, 56, 0.96);
|
||||
margin-bottom: 44rpx;
|
||||
.connected-section > .section-label {
|
||||
margin: 0 40rpx 20rpx;
|
||||
}
|
||||
.wifi-item {
|
||||
position: relative;
|
||||
.refresh-icon {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
.connected-wifi-card,
|
||||
.ios-network-card,
|
||||
.android-list-card {
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.connected-wifi-card {
|
||||
height: 104rpx;
|
||||
padding: 0 40rpx;
|
||||
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;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wifi-ssid {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-size: 28rpx;
|
||||
line-height: 36rpx;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.ios-network-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
.current-network-card {
|
||||
height: 124rpx;
|
||||
}
|
||||
.manual-network-card {
|
||||
height: 156rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.network-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.connected-wifi-ssid {
|
||||
font-weight: 500;
|
||||
.network-desc {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
.other-text {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
.manual-network-card .network-desc {
|
||||
max-width: 484rpx;
|
||||
}
|
||||
.android-list-card {
|
||||
padding: 0 40rpx;
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.android-network-item {
|
||||
box-sizing: border-box;
|
||||
height: 108rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2rpx solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.android-network-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.wifi-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
gap: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.connected-wifi-icons {
|
||||
gap: 14rpx;
|
||||
}
|
||||
.check-icon {
|
||||
width: 28rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 16rpx;
|
||||
width: 40rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.security-icon {
|
||||
width: 24rpx;
|
||||
height: 30rpx;
|
||||
height: 28rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.signal-icon {
|
||||
width: 36rpx;
|
||||
width: 40rpx;
|
||||
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;
|
||||
.wifi-footer {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 550rpx;
|
||||
max-width: 100%;
|
||||
margin-top: auto;
|
||||
padding-top: 60rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.done-btn {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
width: 80%;
|
||||
.location-page {
|
||||
box-sizing: border-box;
|
||||
padding: 0 40rpx 54rpx;
|
||||
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;
|
||||
.location-image-wrap {
|
||||
position: relative;
|
||||
width: 200rpx;
|
||||
height: 266rpx;
|
||||
margin-top: 212rpx;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: rgba(91, 196, 255, 1);
|
||||
border-radius: 5rpx;
|
||||
transition: width 0.4s ease;
|
||||
.location-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 200rpx;
|
||||
height: 266rpx;
|
||||
}
|
||||
.progress-pct {
|
||||
color: rgba(91, 196, 255, 1);
|
||||
.location-shadow {
|
||||
position: absolute;
|
||||
left: 34rpx;
|
||||
bottom: -22rpx;
|
||||
width: 132rpx;
|
||||
height: 50rpx;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.location-desc {
|
||||
display: block;
|
||||
width: 560rpx;
|
||||
max-width: 100%;
|
||||
margin-top: 48rpx;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.location-button {
|
||||
box-sizing: border-box;
|
||||
width: 364rpx;
|
||||
height: 74rpx;
|
||||
margin-top: 38rpx;
|
||||
border: 2rpx solid #ffd800;
|
||||
border-radius: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fed847;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ import OtaModal from "@/components/OtaModal.vue";
|
||||
import {
|
||||
checkUserBindAPI,
|
||||
getAppConfig,
|
||||
getHardwareBoxVersionAPI,
|
||||
getDeviceDetailAPI,
|
||||
getHomeData,
|
||||
getMyDevicesAPI,
|
||||
getScoreRankList,
|
||||
silentLoginAPI,
|
||||
} from "@/apis";
|
||||
import {topThreeColors} from "@/constants";
|
||||
import {useOtaUpdate} from "@/composables/useOtaUpdate";
|
||||
import {getOtaInfoFromDetail, useOtaUpdate} from "@/composables/useOtaUpdate";
|
||||
|
||||
import useStore from "@/store";
|
||||
import {storeToRefs} from "pinia";
|
||||
@@ -69,16 +69,14 @@ const deviceCardAssets = {
|
||||
|
||||
const deviceCardAsset = computed(() => deviceCardAssets[deviceCardState.value]);
|
||||
|
||||
// OTA 相关
|
||||
// 二代设备支持充电时保持开机,仅在设备在线且状态明确为充电中时展示充电标识。
|
||||
const isDeviceCharging = computed(
|
||||
() => deviceCardState.value === "online" && deviceStatus.value?.charging === true
|
||||
);
|
||||
|
||||
const otaVisible = ref(false);
|
||||
const wifiRequiredVisible = ref(false);
|
||||
const otaInfo = ref({
|
||||
versionNumber: "",
|
||||
versionInfo: "",
|
||||
resourceUrl: "",
|
||||
forceUpdate: false,
|
||||
needUpdate: false,
|
||||
});
|
||||
const otaInfo = ref({ versionNumber: "", versionInfo: "", needUpdate: false, forceUpdate: false });
|
||||
const isStartingOta = ref(false);
|
||||
let isCheckingOta = false;
|
||||
let otaCheckQueuedForOnline = false;
|
||||
@@ -93,157 +91,122 @@ const {
|
||||
closeResult: closeOtaResult,
|
||||
} = useOtaUpdate();
|
||||
|
||||
// 获取并保存后端返回的 OTA 版本信息,供弹窗展示和更新接口使用。
|
||||
const applyOtaVersionInfo = (versionInfo) => {
|
||||
otaInfo.value = {
|
||||
versionNumber: versionInfo?.versionNumber || "",
|
||||
versionInfo: versionInfo?.versionInfo || "",
|
||||
resourceUrl: versionInfo?.resourceUrl || "",
|
||||
forceUpdate: Number(versionInfo?.forceUpdate) === 1,
|
||||
needUpdate:
|
||||
versionInfo?.needUpdate === true || Number(versionInfo?.needUpdate) === 1,
|
||||
};
|
||||
};
|
||||
|
||||
// 检查当前设备盒子是否存在可升级版本。
|
||||
// 首页仅用绑定设备详情判断是否需要升级,不再请求旧版本接口。
|
||||
const checkOtaUpdate = async () => {
|
||||
if (
|
||||
isCheckingOta ||
|
||||
otaVisible.value ||
|
||||
otaUpdating.value ||
|
||||
otaResultVisible.value
|
||||
) return;
|
||||
if (isCheckingOta || otaVisible.value || otaUpdating.value || otaResultVisible.value) return;
|
||||
const deviceId = String(device.value.deviceId || "");
|
||||
if (!user.value.id || !deviceId || online.value !== true) return;
|
||||
isCheckingOta = true;
|
||||
|
||||
try {
|
||||
if (
|
||||
online.value !== true ||
|
||||
otaVisible.value ||
|
||||
otaUpdating.value ||
|
||||
otaResultVisible.value
|
||||
) return;
|
||||
|
||||
let versionInfo;
|
||||
try {
|
||||
versionInfo = await getHardwareBoxVersionAPI();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const response = await getDeviceDetailAPI(deviceId);
|
||||
const detail = response?.detail || response?.data?.detail;
|
||||
if (!detail || device.value.deviceId !== deviceId || online.value !== true) return;
|
||||
if (otaVisible.value || otaUpdating.value || otaResultVisible.value) return;
|
||||
applyOtaVersionInfo(versionInfo);
|
||||
if (!otaInfo.value.needUpdate) return;
|
||||
otaInfo.value = getOtaInfoFromDetail(detail);
|
||||
if (!otaInfo.value.needUpdate || !otaInfo.value.versionNumber) return;
|
||||
|
||||
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
|
||||
const now = Date.now();
|
||||
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
|
||||
const dismissed = uni.getStorageSync("ota_dismissed_at");
|
||||
const recentlyDismissed = dismissed?.deviceId === deviceId &&
|
||||
dismissed?.versionNumber === otaInfo.value.versionNumber &&
|
||||
Date.now() - Number(dismissed?.at || 0) < 24 * 60 * 60 * 1000;
|
||||
if (!otaInfo.value.forceUpdate && recentlyDismissed) return;
|
||||
otaVisible.value = true;
|
||||
} catch (error) {
|
||||
console.log("检查固件更新失败", error);
|
||||
} finally {
|
||||
isCheckingOta = false;
|
||||
const shouldRecheckForOnline = otaCheckQueuedForOnline;
|
||||
if (otaCheckQueuedForOnline) {
|
||||
otaCheckQueuedForOnline = false;
|
||||
if (
|
||||
shouldRecheckForOnline &&
|
||||
!otaVisible.value &&
|
||||
!otaUpdating.value &&
|
||||
!otaResultVisible.value
|
||||
) {
|
||||
void checkOtaUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 设备由 WS 通知上线时补查 OTA;已显示任何 OTA 弹窗时保留当前流程和结果。
|
||||
watch(online, (nextOnline, previousOnline) => {
|
||||
if (
|
||||
previousOnline !== false ||
|
||||
nextOnline !== true ||
|
||||
otaVisible.value ||
|
||||
otaUpdating.value ||
|
||||
otaResultVisible.value
|
||||
) return;
|
||||
if (isCheckingOta) {
|
||||
otaCheckQueuedForOnline = true;
|
||||
if (!nextOnline) {
|
||||
otaVisible.value = false;
|
||||
return;
|
||||
}
|
||||
void checkOtaUpdate();
|
||||
if (previousOnline === false) {
|
||||
if (isCheckingOta) otaCheckQueuedForOnline = true;
|
||||
else void checkOtaUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// 任一页面发起或恢复 OTA 后,关闭首页的版本发现弹窗,避免两层弹窗重叠。
|
||||
watch([otaUpdating, otaResultVisible], ([updating, resultVisible]) => {
|
||||
if (!updating && !resultVisible) return;
|
||||
watch(() => device.value.deviceId, () => {
|
||||
otaVisible.value = false;
|
||||
isStartingOta.value = false;
|
||||
otaInfo.value = { versionNumber: "", versionInfo: "", needUpdate: false, forceUpdate: false };
|
||||
});
|
||||
|
||||
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
|
||||
const getOtaWifiUrl = () => {
|
||||
const { versionNumber, resourceUrl } = otaInfo.value;
|
||||
const query = [
|
||||
"source=firmware-update",
|
||||
`versionNumber=${encodeURIComponent(versionNumber)}`,
|
||||
`resourceUrl=${encodeURIComponent(resourceUrl)}`,
|
||||
].join("&");
|
||||
return `/pages/device/ota-wifi?${query}`;
|
||||
};
|
||||
watch([otaUpdating, otaResultVisible], ([updating, resultVisible]) => {
|
||||
if (updating || resultVisible) otaVisible.value = false;
|
||||
});
|
||||
|
||||
const getOtaWifiUrl = () =>
|
||||
`/pages/device/ota-wifi?source=firmware-update&versionNumber=${encodeURIComponent(otaInfo.value.versionNumber)}`;
|
||||
|
||||
// 处理 OTA 弹窗暂不更新,强制更新时不允许关闭。
|
||||
const handleOtaDismiss = () => {
|
||||
if (otaInfo.value.forceUpdate) return;
|
||||
uni.setStorageSync("ota_dismissed_at", Date.now());
|
||||
otaVisible.value = false;
|
||||
};
|
||||
|
||||
// 设备盒子已连 WiFi 时,通过共享 OTA 状态发起更新。
|
||||
const startHomeOtaUpdate = async () => {
|
||||
otaVisible.value = false;
|
||||
await startOtaUpdate({
|
||||
uni.setStorageSync("ota_dismissed_at", {
|
||||
deviceId: device.value.deviceId,
|
||||
versionNumber: otaInfo.value.versionNumber,
|
||||
resourceUrl: otaInfo.value.resourceUrl,
|
||||
onSuccess: () => {
|
||||
otaInfo.value = {...otaInfo.value, needUpdate: false};
|
||||
},
|
||||
at: Date.now(),
|
||||
});
|
||||
isStartingOta.value = false;
|
||||
otaVisible.value = false;
|
||||
};
|
||||
|
||||
// 点击立即更新时先判断设备是否在线并已通过 WiFi 联网,未联网时先展示连接引导。
|
||||
// 点击更新或失败重试时检查设备网络状态,并使用详情中的目标版本号。
|
||||
const handleOtaUpdate = async () => {
|
||||
if (isStartingOta.value) return;
|
||||
isStartingOta.value = true;
|
||||
const currentDeviceStatus = deviceStatus.value;
|
||||
|
||||
if (currentDeviceStatus?.online !== true) {
|
||||
isStartingOta.value = false;
|
||||
uni.showToast({
|
||||
title: "请先开启智能弓",
|
||||
icon: "none",
|
||||
});
|
||||
const deviceId = String(device.value.deviceId || "");
|
||||
try {
|
||||
if (!deviceId || deviceStatus.value?.online !== true) {
|
||||
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Date.now() - Number(currentDeviceStatus?.receivedAt || 0) >
|
||||
DEVICE_STATUS_STALE_TIME
|
||||
) {
|
||||
isStartingOta.value = false;
|
||||
uni.showToast({
|
||||
title: "设备状态同步中,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
const response = await getDeviceDetailAPI(deviceId);
|
||||
const detail = response?.detail || response?.data?.detail;
|
||||
if (!detail || device.value.deviceId !== deviceId) {
|
||||
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(currentDeviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||
startHomeOtaUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
isStartingOta.value = false;
|
||||
otaInfo.value = getOtaInfoFromDetail(detail);
|
||||
if (!otaInfo.value.needUpdate) {
|
||||
otaVisible.value = false;
|
||||
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!otaInfo.value.versionNumber) {
|
||||
uni.showToast({ title: "缺少固件目标版本", icon: "none" });
|
||||
return;
|
||||
}
|
||||
const currentDeviceStatus = deviceStatus.value;
|
||||
if (currentDeviceStatus?.online !== true) {
|
||||
uni.showToast({ title: "请先开启智能弓", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (Date.now() - Number(currentDeviceStatus?.receivedAt || 0) > DEVICE_STATUS_STALE_TIME) {
|
||||
uni.showToast({ title: "设备状态同步中,请稍后重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
otaVisible.value = false;
|
||||
if (String(currentDeviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||
await startOtaUpdate({
|
||||
versionNumber: otaInfo.value.versionNumber,
|
||||
onSuccess: () => { otaInfo.value = { ...otaInfo.value, needUpdate: false }; },
|
||||
});
|
||||
} else {
|
||||
wifiRequiredVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: "获取设备详情失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
isStartingOta.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 从首页固件更新提示进入 WiFi 配置页,并继续沿用当前版本信息。
|
||||
// 从首页更新失败提示进入 WiFi 配置页。
|
||||
const goWifiForOtaUpdate = () => {
|
||||
wifiRequiredVisible.value = false;
|
||||
uni.navigateTo({ url: getOtaWifiUrl() });
|
||||
@@ -252,22 +215,13 @@ const goWifiForOtaUpdate = () => {
|
||||
// 处理 OTA 更新成功后的完成按钮,关闭共享结果弹窗。
|
||||
const handleOtaDone = () => {
|
||||
closeOtaResult();
|
||||
otaInfo.value = { ...otaInfo.value, needUpdate: false };
|
||||
};
|
||||
|
||||
// 处理共享 OTA 更新失败后的重试,先刷新版本参数再重新判断网络状态。
|
||||
// 处理共享 OTA 更新失败后的重试。
|
||||
const handleOtaRetry = async () => {
|
||||
closeOtaResult();
|
||||
try {
|
||||
const versionInfo = await getHardwareBoxVersionAPI();
|
||||
applyOtaVersionInfo(versionInfo);
|
||||
if (!otaInfo.value.needUpdate) return;
|
||||
await handleOtaUpdate();
|
||||
} catch (error) {
|
||||
uni.showToast({
|
||||
title: "获取更新版本失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||
@@ -341,10 +295,6 @@ onShow(async () => {
|
||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
const token = uni.getStorageSync(`${env}_token`);
|
||||
|
||||
if (token || user.value.id) {
|
||||
await checkOtaUpdate();
|
||||
}
|
||||
|
||||
if (!user.value.id && !token) {
|
||||
// showModal.value = true;
|
||||
// try {
|
||||
@@ -396,6 +346,7 @@ onShow(async () => {
|
||||
}, 3000);
|
||||
}
|
||||
await syncHomeDevice();
|
||||
await checkOtaUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,12 +378,10 @@ onShareTimeline(() => {
|
||||
|
||||
<template>
|
||||
<Container :isHome="true" :showBackToGame="true">
|
||||
<!-- 首页版本发现弹窗仅负责触发更新,执行状态由三个页面共享。 -->
|
||||
<OtaModal
|
||||
:visible="otaVisible && !otaUpdating && !otaResultVisible"
|
||||
state="new_version"
|
||||
:version="otaInfo.versionNumber"
|
||||
:description="''"
|
||||
:changelog="otaInfo.versionInfo"
|
||||
:forceUpdate="otaInfo.forceUpdate"
|
||||
@update="handleOtaUpdate"
|
||||
@@ -525,10 +474,11 @@ onShareTimeline(() => {
|
||||
<view
|
||||
v-if="user.id && deviceCardState === 'online'"
|
||||
class="device-status-badge device-status-badge--online"
|
||||
:class="{ 'device-status-badge--charging': isDeviceCharging }"
|
||||
@click.stop="$clickSound(() => toPage('/pages/device/my-device'))"
|
||||
>
|
||||
<image
|
||||
v-if="otaInfo.needUpdate"
|
||||
v-if="otaInfo.needUpdate && otaInfo.versionNumber"
|
||||
class="device-status-refresh"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-state-refresh.png"
|
||||
mode="scaleToFill"
|
||||
@@ -541,6 +491,12 @@ onShareTimeline(() => {
|
||||
/>
|
||||
<text class="device-battery-text">{{ deviceBattery === null ? "--" : deviceBattery }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="isDeviceCharging"
|
||||
class="device-charging-icon"
|
||||
src="/static/device-charging.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image
|
||||
class="device-status-icon device-status-icon--online"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-state-dot--online.png"
|
||||
@@ -806,6 +762,10 @@ onShareTimeline(() => {
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.device-status-badge--charging .device-status-icon--online {
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.device-status-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
@@ -837,11 +797,18 @@ onShareTimeline(() => {
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.device-charging-icon {
|
||||
flex-shrink: 0;
|
||||
width: 15rpx;
|
||||
height: 20rpx;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
|
||||
/* 文字容器只覆盖电池主体(60rpx),避开右侧凸起,使数字在框内水平垂直居中。 */
|
||||
.device-battery-text {
|
||||
position: absolute;
|
||||
top: 9rpx;
|
||||
left: 0x;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -84,9 +84,9 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
activeRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
activeRings: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showSectorLabels: {
|
||||
type: Boolean,
|
||||
@@ -548,7 +548,7 @@ onBeforeUnmount(() => {
|
||||
:highlightOnly="true"
|
||||
:sectorCount="sectorCount"
|
||||
:activeSector="activeSector"
|
||||
:activeRing="activeRing"
|
||||
:activeRings="activeRings"
|
||||
:highlightRefreshToken="highlightRefreshToken"
|
||||
:showSectorLabels="showSectorLabels"
|
||||
/>
|
||||
@@ -877,7 +877,7 @@ onBeforeUnmount(() => {
|
||||
width: calc(100% - 20px);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
margin-top: -40px;
|
||||
margin-top: 15px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.footer > image {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import audioManager, {
|
||||
getTrainingEndAudioKey,
|
||||
getTrainingStartAudioKey,
|
||||
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
||||
} from "@/audioManager";
|
||||
@@ -73,11 +74,11 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
energyPercent: {
|
||||
energyCostPerSec: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
energyReqPercent: {
|
||||
scoreSlot: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
@@ -111,6 +112,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["rhythm-round-complete"]);
|
||||
|
||||
const trainingTitleIconMap = Object.freeze({
|
||||
base:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
|
||||
@@ -143,6 +146,7 @@ let rhythmCountdownTimer = null;
|
||||
let rhythmTransitionTimer = null;
|
||||
let rhythmServerClockOffsetMs = 0;
|
||||
let rhythmSyncGeneration = 0;
|
||||
let rhythmRoundCompletionTimer = null;
|
||||
|
||||
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
|
||||
const isStabilityTraining = computed(
|
||||
@@ -187,17 +191,14 @@ const progressPercent = computed(() => {
|
||||
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
||||
});
|
||||
|
||||
const stabilityEnergyPercent = computed(() =>
|
||||
Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
|
||||
);
|
||||
const stabilityEnergyReqPercent = computed(() =>
|
||||
Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
|
||||
);
|
||||
const stabilityEnergyReached = computed(
|
||||
() =>
|
||||
stabilityEnergyReqPercent.value > 0 &&
|
||||
stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
|
||||
);
|
||||
const stabilityEnergyCostPercentText = computed(() => {
|
||||
const scoreSlot = Number(props.scoreSlot);
|
||||
// 以满能量值为基数,换算每秒损失的能量百分比。
|
||||
const value = scoreSlot > 0
|
||||
? (Math.max(0, Number(props.energyCostPerSec) || 0) / scoreSlot) * 100
|
||||
: 0;
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
||||
});
|
||||
|
||||
const rhythmMarkerPercent = computed(() => {
|
||||
if (!validRhythmRoundTime.value) return 0;
|
||||
@@ -226,7 +227,7 @@ const rhythmTitle = computed(() => {
|
||||
if (!validRhythmHitReq.value) {
|
||||
return `在最后的${validRhythmShootTime.value}秒内射箭`;
|
||||
}
|
||||
return `在最后的${validRhythmShootTime.value}秒并命中${validRhythmHitReq.value}环内`;
|
||||
return `在最后的${validRhythmShootTime.value}秒射箭并命中${validRhythmHitReq.value}环内`;
|
||||
});
|
||||
|
||||
const displayName = computed(() => {
|
||||
@@ -309,6 +310,30 @@ const getRhythmRoundRemainingMs = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const clearRhythmRoundCompletionTimer = () => {
|
||||
if (rhythmRoundCompletionTimer !== null) {
|
||||
clearTimeout(rhythmRoundCompletionTimer);
|
||||
rhythmRoundCompletionTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 与当前显示的倒计时一起计时;消息校准不重建此任务,避免同一轮重复计数。
|
||||
const scheduleRhythmRoundCompletion = (remainingMs) => {
|
||||
if (
|
||||
rhythmRoundCompletionTimer !== null ||
|
||||
!props.start ||
|
||||
!isRhythmTraining.value ||
|
||||
remainingMs <= 0
|
||||
) return;
|
||||
|
||||
rhythmRoundCompletionTimer = setTimeout(() => {
|
||||
rhythmRoundCompletionTimer = null;
|
||||
if (!props.start || !isRhythmTraining.value) return;
|
||||
emit("rhythm-round-complete");
|
||||
scheduleRhythmRoundCompletion(rhythmRoundDurationMs.value);
|
||||
}, remainingMs);
|
||||
};
|
||||
|
||||
const updateRhythmShootWindow = (isInWindow) => {
|
||||
const nextIsInWindow = Boolean(isInWindow);
|
||||
const enteredShootWindow =
|
||||
@@ -367,7 +392,8 @@ const syncRhythmCountdown = async () => {
|
||||
const generation = ++rhythmSyncGeneration;
|
||||
clearRhythmTimers();
|
||||
|
||||
if (!isRhythmTraining.value) {
|
||||
if (!isRhythmTraining.value || !props.start) {
|
||||
clearRhythmRoundCompletionTimer();
|
||||
rhythmTransitionStyle.value = "none";
|
||||
rhythmRemainingMs.value = 0;
|
||||
rhythmRemainingSeconds.value = 0;
|
||||
@@ -385,6 +411,7 @@ const syncRhythmCountdown = async () => {
|
||||
!validRhythmShootTime.value ||
|
||||
!normalizeTimestamp(props.shootWindowStart)
|
||||
) {
|
||||
clearRhythmRoundCompletionTimer();
|
||||
rhythmTransitionStyle.value = "none";
|
||||
rhythmRemainingMs.value = 0;
|
||||
rhythmRemainingSeconds.value = 0;
|
||||
@@ -394,6 +421,7 @@ const syncRhythmCountdown = async () => {
|
||||
|
||||
rhythmTransitionStyle.value = "none";
|
||||
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
|
||||
scheduleRhythmRoundCompletion(rhythmRemainingMs.value);
|
||||
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
|
||||
updateRhythmShootWindow(
|
||||
rhythmRemainingMs.value > 0 &&
|
||||
@@ -465,6 +493,7 @@ watch(
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.start,
|
||||
props.trainingType,
|
||||
props.roundTime,
|
||||
props.shootTime,
|
||||
@@ -496,7 +525,7 @@ async function onReceiveMessage(msg) {
|
||||
audioManager.play("比赛开始");
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("练习结束", false);
|
||||
audioManager.play(getTrainingEndAudioKey(msg.completed), false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
||||
if (props.externalShootResultAudio) return;
|
||||
@@ -553,6 +582,7 @@ onBeforeUnmount(() => {
|
||||
clearTimer();
|
||||
rhythmSyncGeneration += 1;
|
||||
clearRhythmTimers();
|
||||
clearRhythmRoundCompletionTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -568,22 +598,18 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
>
|
||||
<template v-if="isStabilityTraining">
|
||||
<text class="stability-progress__time">{{ remain }}秒</text>
|
||||
<view class="stability-progress__track">
|
||||
<view
|
||||
class="stability-progress__fill"
|
||||
:class="{
|
||||
'stability-progress__fill--reached': stabilityEnergyReached,
|
||||
}"
|
||||
:style="{ width: `${stabilityEnergyPercent}%` }"
|
||||
/>
|
||||
<view
|
||||
class="stability-progress__marker"
|
||||
:style="{ left: `${stabilityEnergyReqPercent}%` }"
|
||||
/>
|
||||
<text class="stability-progress__value">
|
||||
{{ Math.round(stabilityEnergyPercent) }}%
|
||||
<text class="stability-progress__cost">
|
||||
每秒失去{{ stabilityEnergyCostPercentText }}%能量
|
||||
</text>
|
||||
<view v-if="countdownEnabled" class="stability-progress__countdown">
|
||||
<view
|
||||
class="stability-progress__countdown-fill"
|
||||
:style="{
|
||||
width: `${progressPercent}%`,
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text class="stability-progress__countdown-text">剩余{{ remain }}秒</text>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else-if="isRhythmTraining">
|
||||
@@ -811,17 +837,17 @@ onBeforeUnmount(() => {
|
||||
margin: 32rpx 84rpx 0;
|
||||
}
|
||||
|
||||
.stability-progress__time {
|
||||
.stability-progress__cost {
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
color: #ffffff;
|
||||
font-size: 34rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
line-height: 48rpx;
|
||||
line-height: 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stability-progress__track {
|
||||
.stability-progress__countdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24rpx;
|
||||
@@ -830,38 +856,23 @@ onBeforeUnmount(() => {
|
||||
background: #444444;
|
||||
}
|
||||
|
||||
.stability-progress__fill {
|
||||
.stability-progress__countdown-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 18rpx;
|
||||
background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
|
||||
transition: width 240ms linear, background 240ms ease;
|
||||
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
|
||||
}
|
||||
|
||||
.stability-progress__fill--reached {
|
||||
background: linear-gradient(90deg, #a5df62 0%, #61c787 100%);
|
||||
}
|
||||
|
||||
.stability-progress__marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 4rpx;
|
||||
transform: translateX(-2rpx);
|
||||
background: rgba(26, 24, 22, 0.92);
|
||||
}
|
||||
|
||||
.stability-progress__value {
|
||||
.stability-progress__countdown-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #eefaff;
|
||||
color: #fff7de;
|
||||
font-size: 18rpx;
|
||||
line-height: 24rpx;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
percent: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
introPending: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH = 750;
|
||||
const HEIGHT = 228;
|
||||
const MASK_SCALE = 2;
|
||||
const MASK_WIDTH = WIDTH / MASK_SCALE;
|
||||
const MASK_HEIGHT = HEIGHT / MASK_SCALE;
|
||||
const ENTRY_PADDING = 24;
|
||||
const EXIT_PADDING = 32;
|
||||
const MAX_FEATHER = 60;
|
||||
const FULL_TRACK_OFFSET_Y = -3;
|
||||
const ENTRY_EDGE_BLEND_PERCENT = 5;
|
||||
const EXIT_EDGE_BLEND_START_PERCENT = 90;
|
||||
const SWEEP_TRACK_RADIUS = 28;
|
||||
const SWEEP_TRACK_FEATHER = 8;
|
||||
const ENTRY_ANIMATION_DURATION = 1000;
|
||||
const UPDATE_DURATION = 240;
|
||||
const SWEEP_DELAY = 100;
|
||||
const SWEEP_DURATION = 1000;
|
||||
const ASSET_ROOT =
|
||||
"/pages/training/static/stability-energy";
|
||||
const getAssetSource = (filename) => `${ASSET_ROOT}/${filename}`;
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const canvasId = `stability-energy-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const canvasReady = ref(false);
|
||||
const fallbackVisible = ref(true);
|
||||
const normalizedPercent = computed(() =>
|
||||
Math.max(0, Math.min(100, Number(props.percent) || 0))
|
||||
);
|
||||
|
||||
let engine = null;
|
||||
let disposed = false;
|
||||
let animationHandle = null;
|
||||
let renderedPercent = props.introPending ? 100 : normalizedPercent.value;
|
||||
let entryAnimationPlaying = false;
|
||||
let entryAnimationRequested = false;
|
||||
|
||||
const showCanvas = () => {
|
||||
canvasReady.value = true;
|
||||
};
|
||||
|
||||
const requestFrame = (callback) => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
return { type: "frame", id: requestAnimationFrame(callback) };
|
||||
}
|
||||
return {
|
||||
type: "timer",
|
||||
id: setTimeout(() => callback(Date.now()), 16),
|
||||
};
|
||||
};
|
||||
|
||||
const cancelFrame = () => {
|
||||
if (!animationHandle) return;
|
||||
if (
|
||||
animationHandle.type === "frame" &&
|
||||
typeof cancelAnimationFrame === "function"
|
||||
) {
|
||||
cancelAnimationFrame(animationHandle.id);
|
||||
} else {
|
||||
clearTimeout(animationHandle.id);
|
||||
}
|
||||
animationHandle = null;
|
||||
};
|
||||
|
||||
const waitForPaint = () =>
|
||||
new Promise((resolve) => {
|
||||
const schedule = (callback) => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(callback);
|
||||
} else {
|
||||
setTimeout(callback, 16);
|
||||
}
|
||||
};
|
||||
schedule(() => schedule(resolve));
|
||||
});
|
||||
|
||||
const presentCanvas = async () => {
|
||||
showCanvas();
|
||||
await nextTick();
|
||||
await waitForPaint();
|
||||
if (disposed) return false;
|
||||
fallbackVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const smoothStep = (value) => {
|
||||
const amount = Math.max(0, Math.min(1, value));
|
||||
return amount * amount * (3 - 2 * amount);
|
||||
};
|
||||
|
||||
const enableImageSmoothing = (context) => {
|
||||
if ("imageSmoothingEnabled" in context) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
}
|
||||
if ("imageSmoothingQuality" in context) {
|
||||
context.imageSmoothingQuality = "high";
|
||||
}
|
||||
};
|
||||
|
||||
// 复用参考 HTML 的贝塞尔路径,保证能量光带沿靶纸下方的弧线推进。
|
||||
const sampleTrack = () => {
|
||||
const curves = [
|
||||
// 新素材移除了左上方延伸段,进度从箭头端点开始进入弯道。
|
||||
[171, 53, 145, 56, 123, 68, 125, 82],
|
||||
[125, 82, 128, 128, 272, 163, 412, 163],
|
||||
[412, 163, 530, 164, 650, 137, 657, 98],
|
||||
[657, 98, 659, 86, 650, 76, 643, 72],
|
||||
];
|
||||
const points = [{ x: 171, y: 53, distance: 0 }];
|
||||
|
||||
curves.forEach((curve) => {
|
||||
for (let step = 1; step <= 32; step += 1) {
|
||||
const amount = step / 32;
|
||||
const rest = 1 - amount;
|
||||
const point = {
|
||||
x:
|
||||
rest ** 3 * curve[0] +
|
||||
3 * rest ** 2 * amount * curve[2] +
|
||||
3 * rest * amount ** 2 * curve[4] +
|
||||
amount ** 3 * curve[6],
|
||||
y:
|
||||
rest ** 3 * curve[1] +
|
||||
3 * rest ** 2 * amount * curve[3] +
|
||||
3 * rest * amount ** 2 * curve[5] +
|
||||
amount ** 3 * curve[7],
|
||||
};
|
||||
const previous = points[points.length - 1];
|
||||
point.distance =
|
||||
previous.distance + Math.hypot(point.x - previous.x, point.y - previous.y);
|
||||
points.push(point);
|
||||
}
|
||||
});
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
const createOffscreenCanvas = (canvas, width = WIDTH, height = HEIGHT) => {
|
||||
let layerCanvas = null;
|
||||
|
||||
if (typeof canvas.createOffscreenCanvas === "function") {
|
||||
try {
|
||||
layerCanvas = canvas.createOffscreenCanvas({ type: "2d", width, height });
|
||||
} catch (error) {
|
||||
layerCanvas = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!layerCanvas &&
|
||||
typeof wx !== "undefined" &&
|
||||
typeof wx.createOffscreenCanvas === "function"
|
||||
) {
|
||||
try {
|
||||
layerCanvas = wx.createOffscreenCanvas({ type: "2d", width, height });
|
||||
} catch (error) {
|
||||
layerCanvas = null;
|
||||
}
|
||||
}
|
||||
if (!layerCanvas && typeof document !== "undefined") {
|
||||
layerCanvas = document.createElement("canvas");
|
||||
layerCanvas.width = width;
|
||||
layerCanvas.height = height;
|
||||
}
|
||||
|
||||
if (!layerCanvas) throw new Error("Offscreen canvas unavailable");
|
||||
layerCanvas.width = width;
|
||||
layerCanvas.height = height;
|
||||
const context = layerCanvas.getContext("2d");
|
||||
if (!context) throw new Error("Offscreen canvas context unavailable");
|
||||
enableImageSmoothing(context);
|
||||
return { canvas: layerCanvas, context };
|
||||
};
|
||||
|
||||
const loadCanvasImage = (canvas, source) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image =
|
||||
typeof canvas.createImage === "function"
|
||||
? canvas.createImage()
|
||||
: typeof Image !== "undefined"
|
||||
? new Image()
|
||||
: null;
|
||||
if (!image) {
|
||||
reject(new Error("Canvas image unavailable"));
|
||||
return;
|
||||
}
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Image unavailable: ${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
|
||||
const createEnergyEngine = async (canvas, preloadedFullImage = null) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas unavailable");
|
||||
enableImageSmoothing(context);
|
||||
|
||||
const maskLayer = createOffscreenCanvas(canvas, MASK_WIDTH, MASK_HEIGHT);
|
||||
const emptyLayer = createOffscreenCanvas(canvas);
|
||||
const fullLayer = createOffscreenCanvas(canvas);
|
||||
const glowLayer = createOffscreenCanvas(canvas);
|
||||
const sweepLayer = createOffscreenCanvas(canvas);
|
||||
const track = sampleTrack();
|
||||
const pathLength = track[track.length - 1].distance;
|
||||
const revealStarts = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||
const revealRates = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||
const sweepTrackAlpha = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||
const maskPixels = maskLayer.context.createImageData(MASK_WIDTH, MASK_HEIGHT);
|
||||
maskPixels.data.fill(255);
|
||||
|
||||
const images = await Promise.all([
|
||||
loadCanvasImage(canvas, getAssetSource("progress-empty.png")),
|
||||
preloadedFullImage ||
|
||||
loadCanvasImage(canvas, getAssetSource("progress-full.png")),
|
||||
loadCanvasImage(canvas, getAssetSource("progress-glow.png")),
|
||||
]);
|
||||
|
||||
const segments = track.slice(1).map((end, index) => {
|
||||
const start = track[index];
|
||||
const deltaX = end.x - start.x;
|
||||
const deltaY = end.y - start.y;
|
||||
return {
|
||||
start,
|
||||
deltaX,
|
||||
deltaY,
|
||||
length: end.distance - start.distance,
|
||||
inverseSquaredLength: 1 / (deltaX ** 2 + deltaY ** 2),
|
||||
};
|
||||
});
|
||||
|
||||
// 初始化每个蒙版像素抵达能量路径的时刻,后续更新只重绘,不重复计算。
|
||||
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||
const pixelX = ((pixel % MASK_WIDTH) + 0.5) * MASK_SCALE;
|
||||
const pixelY = (Math.floor(pixel / MASK_WIDTH) + 0.5) * MASK_SCALE;
|
||||
let nearestSquaredDistance = Infinity;
|
||||
let arrivalDistance = 0;
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
const offsetX = pixelX - segment.start.x;
|
||||
const offsetY = pixelY - segment.start.y;
|
||||
const projection =
|
||||
(offsetX * segment.deltaX + offsetY * segment.deltaY) *
|
||||
segment.inverseSquaredLength;
|
||||
const amount = Math.max(0, Math.min(1, projection));
|
||||
const squaredDistance =
|
||||
(offsetX - segment.deltaX * amount) ** 2 +
|
||||
(offsetY - segment.deltaY * amount) ** 2;
|
||||
if (squaredDistance >= nearestSquaredDistance) continue;
|
||||
|
||||
nearestSquaredDistance = squaredDistance;
|
||||
arrivalDistance = segment.start.distance + amount * segment.length;
|
||||
if (index === 0 && projection < 0) {
|
||||
arrivalDistance = Math.max(-ENTRY_PADDING, projection * segment.length);
|
||||
}
|
||||
if (index === segments.length - 1 && projection > 1) {
|
||||
arrivalDistance = Math.min(
|
||||
pathLength + EXIT_PADDING,
|
||||
segment.start.distance + projection * segment.length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nearestDistance = Math.sqrt(nearestSquaredDistance);
|
||||
const feather = Math.min(
|
||||
MAX_FEATHER,
|
||||
16 + nearestDistance * 0.9
|
||||
);
|
||||
revealStarts[pixel] = arrivalDistance;
|
||||
revealRates[pixel] = 1 / feather;
|
||||
sweepTrackAlpha[pixel] =
|
||||
1 -
|
||||
smoothStep(
|
||||
(nearestDistance - SWEEP_TRACK_RADIUS) / SWEEP_TRACK_FEATHER
|
||||
);
|
||||
}
|
||||
|
||||
const temporary = new Float32Array(revealStarts.length);
|
||||
const radius = 3;
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
for (let row = 0; row < MASK_HEIGHT; row += 1) {
|
||||
for (let column = 0; column < MASK_WIDTH; column += 1) {
|
||||
let sum = 0;
|
||||
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||
const neighbor = Math.max(
|
||||
0,
|
||||
Math.min(MASK_WIDTH - 1, column + offset)
|
||||
);
|
||||
sum += revealStarts[row * MASK_WIDTH + neighbor];
|
||||
}
|
||||
temporary[row * MASK_WIDTH + column] = sum / (radius * 2 + 1);
|
||||
}
|
||||
}
|
||||
for (let row = 0; row < MASK_HEIGHT; row += 1) {
|
||||
for (let column = 0; column < MASK_WIDTH; column += 1) {
|
||||
let sum = 0;
|
||||
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||
const neighbor = Math.max(
|
||||
0,
|
||||
Math.min(MASK_HEIGHT - 1, row + offset)
|
||||
);
|
||||
sum += temporary[neighbor * MASK_WIDTH + column];
|
||||
}
|
||||
revealStarts[row * MASK_WIDTH + column] = sum / (radius * 2 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trackPosition = (distance) => {
|
||||
const boundedDistance = Math.max(0, Math.min(pathLength, distance));
|
||||
let upper = 1;
|
||||
while (
|
||||
upper < track.length - 1 &&
|
||||
track[upper].distance < boundedDistance
|
||||
) {
|
||||
upper += 1;
|
||||
}
|
||||
const start = track[upper - 1];
|
||||
const end = track[upper];
|
||||
const amount =
|
||||
(boundedDistance - start.distance) / (end.distance - start.distance);
|
||||
return {
|
||||
x: start.x + (end.x - start.x) * amount,
|
||||
y: start.y + (end.y - start.y) * amount,
|
||||
};
|
||||
};
|
||||
|
||||
// 满轨素材的视觉中心比空轨低约 3px,所有满轨绘制统一上移补偿。
|
||||
const drawFullTrack = (targetContext) => {
|
||||
targetContext.drawImage(
|
||||
images[1],
|
||||
0,
|
||||
FULL_TRACK_OFFSET_Y,
|
||||
WIDTH,
|
||||
HEIGHT
|
||||
);
|
||||
};
|
||||
|
||||
const maskedImage = (layer, image, operation, offsetY = 0) => {
|
||||
layer.context.globalCompositeOperation = "source-over";
|
||||
layer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
layer.context.drawImage(image, 0, offsetY, WIDTH, HEIGHT);
|
||||
layer.context.globalCompositeOperation = operation;
|
||||
layer.context.drawImage(maskLayer.canvas, 0, 0, WIDTH, HEIGHT);
|
||||
layer.context.globalCompositeOperation = "source-over";
|
||||
};
|
||||
|
||||
const draw = (progress) => {
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
if (progress <= 0) {
|
||||
context.drawImage(images[0], 0, 0, WIDTH, HEIGHT);
|
||||
return;
|
||||
}
|
||||
if (progress >= 100) {
|
||||
drawFullTrack(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 主体百分比仍映射轨道本体,头尾仅补偿端点羽化,避免整图与蒙版切换时跳变。
|
||||
const distance = (progress / 100) * pathLength;
|
||||
const entryBlend = smoothStep(progress / ENTRY_EDGE_BLEND_PERCENT);
|
||||
const exitBlend = smoothStep(
|
||||
(progress - EXIT_EDGE_BLEND_START_PERCENT) /
|
||||
(100 - EXIT_EDGE_BLEND_START_PERCENT)
|
||||
);
|
||||
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||
const trackAlpha =
|
||||
smoothStep(
|
||||
(distance - revealStarts[pixel]) * revealRates[pixel]
|
||||
) * entryBlend;
|
||||
maskPixels.data[pixel * 4 + 3] = Math.round(
|
||||
255 * (trackAlpha + (1 - trackAlpha) * exitBlend)
|
||||
);
|
||||
}
|
||||
maskLayer.context.putImageData(maskPixels, 0, 0);
|
||||
maskedImage(emptyLayer, images[0], "destination-out");
|
||||
maskedImage(
|
||||
fullLayer,
|
||||
images[1],
|
||||
"destination-in",
|
||||
FULL_TRACK_OFFSET_Y
|
||||
);
|
||||
context.drawImage(emptyLayer.canvas, 0, 0);
|
||||
context.globalCompositeOperation = "lighter";
|
||||
context.drawImage(fullLayer.canvas, 0, 0);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
|
||||
const headBlend =
|
||||
smoothStep(progress / 10) * (1 - smoothStep((progress - 85) / 15));
|
||||
const position = trackPosition(distance - 16);
|
||||
const headSize = Math.round(72 * (0.55 + 0.45 * headBlend));
|
||||
const headX = Math.round(position.x - headSize / 2);
|
||||
const headY = Math.round(position.y - headSize / 2);
|
||||
glowLayer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
glowLayer.context.drawImage(
|
||||
images[2],
|
||||
headX,
|
||||
headY,
|
||||
headSize,
|
||||
headSize
|
||||
);
|
||||
glowLayer.context.globalCompositeOperation = "destination-in";
|
||||
drawFullTrack(glowLayer.context);
|
||||
glowLayer.context.globalCompositeOperation = "source-over";
|
||||
context.globalAlpha = 0.8 * headBlend;
|
||||
context.drawImage(glowLayer.canvas, 0, 0);
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const drawSweep = (amount) => {
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
drawFullTrack(context);
|
||||
|
||||
const lead = pathLength * 0.03;
|
||||
const core = pathLength * 0.08;
|
||||
const tail = pathLength * 0.18;
|
||||
const sweepCenter = -tail + amount * (pathLength + lead + tail);
|
||||
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||
const offset = sweepCenter - revealStarts[pixel];
|
||||
let intensity = 0;
|
||||
if (offset >= -lead && offset < 0) {
|
||||
intensity = smoothStep(1 + offset / lead);
|
||||
} else if (offset >= 0 && offset <= core) {
|
||||
intensity = 1;
|
||||
} else if (offset > core && offset <= core + tail) {
|
||||
intensity = 1 - smoothStep((offset - core) / tail);
|
||||
}
|
||||
maskPixels.data[pixel * 4 + 3] = Math.round(
|
||||
255 * intensity * sweepTrackAlpha[pixel]
|
||||
);
|
||||
}
|
||||
maskLayer.context.putImageData(maskPixels, 0, 0);
|
||||
|
||||
sweepLayer.context.globalCompositeOperation = "source-over";
|
||||
sweepLayer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
drawFullTrack(sweepLayer.context);
|
||||
sweepLayer.context.globalCompositeOperation = "source-in";
|
||||
sweepLayer.context.fillStyle = "#fffbd8";
|
||||
sweepLayer.context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
sweepLayer.context.globalCompositeOperation = "destination-in";
|
||||
sweepLayer.context.drawImage(maskLayer.canvas, 0, 0, WIDTH, HEIGHT);
|
||||
sweepLayer.context.globalCompositeOperation = "source-over";
|
||||
|
||||
// 普通透明叠加只提亮轨道核心,不把满轨素材的外围光晕染白。
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.globalAlpha = 0.9;
|
||||
context.drawImage(sweepLayer.canvas, 0, 0);
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = "source-over";
|
||||
};
|
||||
|
||||
return { draw, drawSweep };
|
||||
};
|
||||
|
||||
const runCompletionSweep = () => {
|
||||
cancelFrame();
|
||||
let startedAt = null;
|
||||
const step = (timestamp) => {
|
||||
if (!engine || disposed) return;
|
||||
if (startedAt === null) startedAt = timestamp;
|
||||
const elapsed = timestamp - startedAt;
|
||||
if (elapsed < SWEEP_DELAY) {
|
||||
engine.draw(100);
|
||||
animationHandle = requestFrame(step);
|
||||
return;
|
||||
}
|
||||
const amount = Math.min(1, (elapsed - SWEEP_DELAY) / SWEEP_DURATION);
|
||||
engine.drawSweep(amount);
|
||||
if (amount < 1) {
|
||||
animationHandle = requestFrame(step);
|
||||
} else {
|
||||
animationHandle = null;
|
||||
engine.draw(100);
|
||||
}
|
||||
};
|
||||
animationHandle = requestFrame(step);
|
||||
};
|
||||
|
||||
// 服务端能量既会因命中增加,也会按秒下降,因此双向过渡都以最新快照为准。
|
||||
const drawPercent = (targetPercent, animate = true) => {
|
||||
if (!engine) return;
|
||||
cancelFrame();
|
||||
const from = renderedPercent;
|
||||
const target = Math.max(0, Math.min(100, targetPercent));
|
||||
const reachesFull = from < 100 && target >= 100;
|
||||
|
||||
if (!animate || Math.abs(target - from) < 0.01) {
|
||||
renderedPercent = target;
|
||||
engine.draw(target);
|
||||
if (reachesFull) runCompletionSweep();
|
||||
return;
|
||||
}
|
||||
|
||||
let startedAt = null;
|
||||
const step = (timestamp) => {
|
||||
if (!engine || disposed) return;
|
||||
if (startedAt === null) startedAt = timestamp;
|
||||
const amount = Math.min(1, (timestamp - startedAt) / UPDATE_DURATION);
|
||||
renderedPercent = from + (target - from) * smoothStep(amount);
|
||||
engine.draw(renderedPercent);
|
||||
if (amount < 1) {
|
||||
animationHandle = requestFrame(step);
|
||||
} else {
|
||||
animationHandle = null;
|
||||
renderedPercent = target;
|
||||
engine.draw(target);
|
||||
if (reachesFull) runCompletionSweep();
|
||||
}
|
||||
};
|
||||
animationHandle = requestFrame(step);
|
||||
};
|
||||
|
||||
// 首次进入时先展示满能量,再用 1 秒线性动画回落到 0%。
|
||||
const runEntryAnimation = () => {
|
||||
if (!engine || disposed) return;
|
||||
cancelFrame();
|
||||
entryAnimationRequested = false;
|
||||
entryAnimationPlaying = true;
|
||||
renderedPercent = 100;
|
||||
engine.draw(100);
|
||||
showCanvas();
|
||||
|
||||
let startedAt = null;
|
||||
const step = (timestamp) => {
|
||||
if (!engine || disposed) return;
|
||||
if (startedAt === null) startedAt = timestamp;
|
||||
const amount = Math.min(
|
||||
1,
|
||||
(timestamp - startedAt) / ENTRY_ANIMATION_DURATION
|
||||
);
|
||||
renderedPercent = 100 * (1 - amount);
|
||||
engine.draw(renderedPercent);
|
||||
|
||||
if (amount < 1) {
|
||||
animationHandle = requestFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
animationHandle = null;
|
||||
entryAnimationPlaying = false;
|
||||
renderedPercent = 0;
|
||||
engine.draw(0);
|
||||
|
||||
// 动画期间可能收到服务端快照,结束后再追到最新真实能量。
|
||||
if (normalizedPercent.value > 0) {
|
||||
drawPercent(normalizedPercent.value);
|
||||
}
|
||||
};
|
||||
|
||||
animationHandle = requestFrame(step);
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
await nextTick();
|
||||
const info = await new Promise((resolve) => {
|
||||
let query = uni.createSelectorQuery();
|
||||
if (instance?.proxy && typeof query.in === "function") {
|
||||
query = query.in(instance.proxy);
|
||||
}
|
||||
query
|
||||
.select(`#${canvasId}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((result) => resolve(result?.[0] || null));
|
||||
});
|
||||
|
||||
if (disposed) return;
|
||||
if (!info?.node?.getContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canvas = info.node;
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas unavailable");
|
||||
enableImageSmoothing(context);
|
||||
|
||||
let preloadedFullImage = null;
|
||||
if (props.introPending) {
|
||||
preloadedFullImage = await loadCanvasImage(
|
||||
canvas,
|
||||
getAssetSource("progress-full.png")
|
||||
);
|
||||
if (disposed) return;
|
||||
context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
context.drawImage(
|
||||
preloadedFullImage,
|
||||
0,
|
||||
FULL_TRACK_OFFSET_Y,
|
||||
WIDTH,
|
||||
HEIGHT
|
||||
);
|
||||
renderedPercent = 100;
|
||||
if (!(await presentCanvas())) return;
|
||||
// 先让满能量首帧完成合成,再开始较重的遮罩和轨道计算。
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (disposed) return;
|
||||
}
|
||||
|
||||
engine = await createEnergyEngine(canvas, preloadedFullImage);
|
||||
if (disposed) return;
|
||||
if (props.introPending || entryAnimationRequested) {
|
||||
renderedPercent = 100;
|
||||
engine.draw(100);
|
||||
showCanvas();
|
||||
if (!props.introPending && entryAnimationRequested) {
|
||||
runEntryAnimation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedPercent = normalizedPercent.value;
|
||||
engine.draw(renderedPercent);
|
||||
await presentCanvas();
|
||||
} catch (error) {
|
||||
console.log("stability energy canvas unavailable", error);
|
||||
}
|
||||
};
|
||||
|
||||
watch(normalizedPercent, (value) => {
|
||||
if (!engine || props.introPending || entryAnimationPlaying) return;
|
||||
drawPercent(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.introPending,
|
||||
(pending, wasPending) => {
|
||||
if (pending) {
|
||||
entryAnimationRequested = false;
|
||||
if (!engine) return;
|
||||
cancelFrame();
|
||||
entryAnimationPlaying = false;
|
||||
renderedPercent = 100;
|
||||
engine.draw(100);
|
||||
showCanvas();
|
||||
return;
|
||||
}
|
||||
if (!wasPending) return;
|
||||
|
||||
entryAnimationRequested = true;
|
||||
if (engine) runEntryAnimation();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(initialize);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
cancelFrame();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="stability-energy-track" aria-label="稳定训练能量进度">
|
||||
<image
|
||||
v-if="fallbackVisible"
|
||||
class="stability-energy-track__image"
|
||||
:src="getAssetSource('progress-full.png')"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
:class="[
|
||||
'stability-energy-track__canvas',
|
||||
canvasReady ? 'stability-energy-track__canvas--ready' : '',
|
||||
]"
|
||||
type="2d"
|
||||
:width="WIDTH"
|
||||
:height="HEIGHT"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stability-energy-track {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
width: 100%;
|
||||
height: 228rpx;
|
||||
margin: -164rpx 0 -80rpx;
|
||||
margin-left: -10rpx;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stability-energy-track__image,
|
||||
.stability-energy-track__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stability-energy-track__image {
|
||||
z-index: 0;
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
|
||||
.stability-energy-track__canvas {
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stability-energy-track__canvas--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,8 @@ const previewLines = computed(() => {
|
||||
: [{ text: line }];
|
||||
const parts = rawParts
|
||||
.map((part) => ({
|
||||
text: String(part?.text ?? part ?? "").trim(),
|
||||
text: String(part?.text ?? part ?? ""),
|
||||
highlight: part?.highlight === true,
|
||||
}))
|
||||
.filter((part) => part.text);
|
||||
|
||||
@@ -48,6 +49,7 @@ const previewLines = computed(() => {
|
||||
<text
|
||||
v-for="(part, partIndex) in line.parts"
|
||||
:key="partIndex"
|
||||
:class="{ 'difficulty-preview__highlight': part.highlight }"
|
||||
>{{ part.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -89,7 +91,7 @@ const previewLines = computed(() => {
|
||||
}
|
||||
|
||||
.difficulty-preview__copy {
|
||||
width: 84%;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
color: #ffffff;
|
||||
@@ -102,4 +104,9 @@ const previewLines = computed(() => {
|
||||
.difficulty-preview__line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.difficulty-preview__highlight {
|
||||
color: #ffd543;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,50 +110,51 @@ const createDifficultySummary = (item = {}) => {
|
||||
const timeLimit = toNumber(item.time_limit);
|
||||
const hitReq = toNumber(item.hit_req);
|
||||
const totalReq = toNumber(item.total_req);
|
||||
const energyReqPercent = toNumber(item.energy_req_percent);
|
||||
const shootingTimeText =
|
||||
timeLimit > 0 ? `在${timeLimit}秒内进行射箭` : "不限时进行射箭";
|
||||
const enduranceTimeText =
|
||||
timeLimit > 0
|
||||
? `在${timeLimit}秒内完成${arrows}箭`
|
||||
: `不限时完成${arrows}箭`;
|
||||
const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows;
|
||||
const timeParts = timeLimit > 0
|
||||
? [{ text: `${timeLimit}`, highlight: true }, { text: "秒内," }]
|
||||
: [{ text: "不限时," }];
|
||||
|
||||
const summaryMap = {
|
||||
base: [
|
||||
shootingTimeText,
|
||||
`需要有${arrows}箭命中${hitReq}环内`,
|
||||
{ parts: [
|
||||
...timeParts,
|
||||
{ text: "命中" }, { text: `${arrows}`, highlight: true },
|
||||
{ text: "支" }, { text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环的箭" },
|
||||
] },
|
||||
],
|
||||
endurance: [
|
||||
enduranceTimeText,
|
||||
`且累计环数大于${totalReq}环`,
|
||||
{ parts: [
|
||||
...timeParts,
|
||||
{ text: "共射出" }, { text: `≥${arrows}`, highlight: true },
|
||||
{ text: "支箭,且总环数" }, { text: `≥${totalReq}`, highlight: true },
|
||||
{ text: "环" },
|
||||
] },
|
||||
],
|
||||
precision: [
|
||||
shootingTimeText,
|
||||
`需要有${arrows}箭命中高亮区域`,
|
||||
{ parts: [
|
||||
...timeParts,
|
||||
{ text: "命中" }, { text: `${arrows}`, highlight: true },
|
||||
{ text: "箭高亮区域" },
|
||||
] },
|
||||
],
|
||||
rhythm: [
|
||||
arrows > 0
|
||||
? `需要在指定时间节点射出${arrows}射箭`
|
||||
: "需要在指定时间节点射箭",
|
||||
hitReq > 0 ? `且每箭命中${hitReq}环内` : "且每箭命中指定区域",
|
||||
{ parts: [
|
||||
{ text: `${arrows}`, highlight: true },
|
||||
{ text: "个节奏内,随节奏射箭,命中" },
|
||||
{ text: `${rhythmRequiredArrows}`, highlight: true },
|
||||
{ text: "箭成绩" }, { text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环且踩中节拍区间" },
|
||||
] },
|
||||
],
|
||||
stability: [
|
||||
{
|
||||
parts: [
|
||||
{ text: timeLimit > 0 ? "在" : "" },
|
||||
{
|
||||
text: timeLimit > 0 ? `${timeLimit}秒` : "不限时",
|
||||
highlight: true,
|
||||
},
|
||||
{ text: timeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
|
||||
{ text: `${hitReq}环`, highlight: true },
|
||||
{ text: "可获得能量" },
|
||||
],
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{ text: "计时结束能量需要大于" },
|
||||
{ text: `${energyReqPercent}%`, highlight: true },
|
||||
...timeParts,
|
||||
{ text: "每射一箭" },
|
||||
{ text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环可为能量槽充能,能量充满即过关" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -628,6 +629,7 @@ const createPracticeQuery = (difficulty) => {
|
||||
},
|
||||
rhythm: {
|
||||
hitReq: toNumber(difficulty.hit_req),
|
||||
totalReq: toNumber(difficulty.total_req, toNumber(difficulty.arrows)),
|
||||
mode: toNumber(difficulty.mode),
|
||||
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
||||
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ShootProgress from "./components/ShootProgress.vue";
|
||||
import StabilityEnergyTrack from "./components/StabilityEnergyTrack.vue";
|
||||
import BowTarget from "./components/BowTarget.vue";
|
||||
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
|
||||
import ScoreResult from "./components/ScoreResult.vue";
|
||||
@@ -48,6 +49,7 @@ const { user } = storeToRefs(store);
|
||||
const sound = ref(true);
|
||||
const start = ref(false);
|
||||
const practiceStarting = ref(false);
|
||||
const stabilityEnergyIntroPending = ref(false);
|
||||
const pageStages = Object.freeze({
|
||||
DISTANCE: "distance",
|
||||
SHOOTING: "shooting",
|
||||
@@ -100,19 +102,19 @@ const rhythmServerTimestamp = ref(0);
|
||||
const rhythmFallbackWindowStart = ref(0);
|
||||
const rhythmFallbackTimestamp = ref(0);
|
||||
const rhythmHasActiveServerAnchor = ref(false);
|
||||
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。
|
||||
const rhythmTargetArrows = ref(0);
|
||||
// 仅由前端每轮倒计时完成事件累加,不根据服务端箭数推算。
|
||||
const rhythmCompletedRounds = ref(0);
|
||||
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
||||
const visiblePrecisionTarget = ref({
|
||||
randomBlock: 0,
|
||||
randomRingArea: 0,
|
||||
randomRingAreas: [],
|
||||
});
|
||||
const trainingDifficultyStorageKey = "training-selection";
|
||||
const useHighlightTest = ref(false);
|
||||
const highlightTestState = ref({
|
||||
blocks: 8,
|
||||
randomBlock: 1,
|
||||
randomRingArea: 0,
|
||||
randomRingAreas: [],
|
||||
});
|
||||
const highlightTestTimer = ref(null);
|
||||
const serverAddr = ref("");
|
||||
@@ -133,11 +135,14 @@ let shotPresentationGeneration = 0;
|
||||
let nextDifficultyRequest = null;
|
||||
let nextDifficultyRequestGeneration = 0;
|
||||
const audioWaiters = new Set();
|
||||
let stabilityEnergyIntroGeneration = 0;
|
||||
let stabilityEnergyIntroTimer = null;
|
||||
const shotEffectWaiters = new Map();
|
||||
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
|
||||
const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
|
||||
const AUDIO_TIMEOUT_BASE = 3500;
|
||||
const AUDIO_TIMEOUT_PER_KEY = 2600;
|
||||
const STABILITY_ENERGY_ENTRY_DELAY_MS = 1000;
|
||||
const AUDIO_TIMEOUT_MAX = 12000;
|
||||
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
|
||||
let lastRuntimeDiagnosticAt = 0;
|
||||
@@ -195,9 +200,23 @@ const getPositiveInteger = (value) => {
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
const normalizePrecisionRingAreas = (source = {}) => {
|
||||
const multipleRings = Array.isArray(source.randomRingAreas)
|
||||
? [...new Set(source.randomRingAreas.map(getPositiveInteger))].filter(
|
||||
(ring) => ring >= 1 && ring <= 10
|
||||
)
|
||||
: [];
|
||||
|
||||
if (multipleRings.length > 0) return multipleRings;
|
||||
|
||||
const singleRing = getPositiveInteger(source.randomRingArea);
|
||||
return singleRing >= 1 && singleRing <= 10 ? [singleRing] : [];
|
||||
};
|
||||
|
||||
const getPrecisionTargetSnapshot = (source = {}) => ({
|
||||
randomBlock: getPositiveInteger(source.randomBlock),
|
||||
randomRingArea: getPositiveInteger(source.randomRingArea),
|
||||
// 多环字段优先;后端仅在单环时继续下发旧单值字段。
|
||||
randomRingAreas: normalizePrecisionRingAreas(source),
|
||||
});
|
||||
|
||||
const applyVisiblePrecisionTarget = (source = {}) => {
|
||||
@@ -345,6 +364,15 @@ const stabilityEnergyPercent = computed(() => {
|
||||
(stabilityCurrentEnergy.value / stabilityScoreSlot.value) * 100
|
||||
);
|
||||
});
|
||||
const stabilityEnergyCostPerSec = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
getPracticeNumber(
|
||||
practiceInfo.value.energyCostPerSec,
|
||||
trainingParams.value.energyCostPerSec
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const initializeRhythmFirstRoundCountdown = () => {
|
||||
rhythmHasActiveServerAnchor.value = false;
|
||||
@@ -384,14 +412,13 @@ const precisionRandomBlock = computed(() => {
|
||||
return block <= precisionBlocks.value ? block : 0;
|
||||
});
|
||||
|
||||
const precisionRandomRingArea = computed(() => {
|
||||
const ring = getPositiveInteger(
|
||||
const precisionRandomRingAreas = computed(() =>
|
||||
normalizePrecisionRingAreas(
|
||||
useHighlightTest.value
|
||||
? highlightTestState.value.randomRingArea
|
||||
: visiblePrecisionTarget.value.randomRingArea
|
||||
? highlightTestState.value
|
||||
: visiblePrecisionTarget.value
|
||||
)
|
||||
);
|
||||
return ring >= 1 && ring <= 10 ? ring : 0;
|
||||
});
|
||||
|
||||
// 只展示后端进度,不在前端重复判断训练是否完成。
|
||||
const trainingCopy = computed(() => {
|
||||
@@ -413,10 +440,11 @@ const trainingCopy = computed(() => {
|
||||
return {
|
||||
inline: true,
|
||||
details: [
|
||||
{ text: "计时结束前需要有" },
|
||||
{ text: "命中 " },
|
||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
||||
{ text: "箭命中" },
|
||||
{ text: `${hitReq}环`, highlight: true },
|
||||
{ text: " 支" },
|
||||
{ text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环的箭" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -436,9 +464,9 @@ const trainingCopy = computed(() => {
|
||||
return {
|
||||
inline: true,
|
||||
details: [
|
||||
{ text: "计时结束前完成" },
|
||||
{ text: "射出≥ " },
|
||||
{ text: `(${currentArrows}/${targetArrows})`, highlight: true },
|
||||
{ text: "箭且累计命中" },
|
||||
{ text: " 支箭,总环数达到≥ " },
|
||||
{ text: `(${currentRings}/${targetRings})`, highlight: true },
|
||||
{ text: " 环" },
|
||||
],
|
||||
@@ -459,7 +487,7 @@ const trainingCopy = computed(() => {
|
||||
return {
|
||||
inline: true,
|
||||
details: [
|
||||
{ text: "射箭命中高亮区域需完成" },
|
||||
{ text: "命中高亮区域 " },
|
||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
||||
{ text: " 箭" },
|
||||
],
|
||||
@@ -467,28 +495,26 @@ const trainingCopy = computed(() => {
|
||||
}
|
||||
|
||||
if (trainingType.value === "rhythm") {
|
||||
const targetArrows = getPositiveInteger(rhythmTargetArrows.value);
|
||||
const arrowsLeft = Math.min(
|
||||
targetArrows,
|
||||
Math.max(
|
||||
0,
|
||||
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
|
||||
)
|
||||
);
|
||||
const completedArrows = targetArrows - arrowsLeft;
|
||||
// 总节奏数沿用关卡配置,与选择页保持一致,不使用消息中的箭数覆盖。
|
||||
const totalArrows = getPositiveInteger(total.value);
|
||||
// 节奏数由前端倒计时累计,达标箭数与下方金色箭记录使用同一判定。
|
||||
const targetArrows =
|
||||
getPositiveInteger(practiceInfo.value.targetArrows) ||
|
||||
getPositiveInteger(trainingParams.value.totalReq) || totalArrows;
|
||||
// 展示当前节奏序号,从 1 开始;内部仍累计已完成次数。
|
||||
const currentRound = Math.min(totalArrows, rhythmCompletedRounds.value + 1);
|
||||
const hitReq = rhythmHitReq.value;
|
||||
const qualifiedArrows = scores.value.filter((arrow) => arrow?.ok === true).length;
|
||||
|
||||
return {
|
||||
inline: true,
|
||||
details: [
|
||||
{ text: "在进度条读取到高亮区间时射箭命中" },
|
||||
{
|
||||
text: hitReq > 0 ? `${hitReq}环内` : "指定区域",
|
||||
highlight: true,
|
||||
},
|
||||
{ text: ",需完成" },
|
||||
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
|
||||
{ text: `(${currentRound}/${totalArrows})`, highlight: true },
|
||||
{ text: " 个节奏内,跟随节奏射箭,命中 " },
|
||||
{ text: `(${qualifiedArrows}/${targetArrows})`, highlight: true },
|
||||
{ text: " 箭" },
|
||||
{ text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环且踩中节拍区间" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -498,26 +524,12 @@ const trainingCopy = computed(() => {
|
||||
practiceInfo.value.hitReq,
|
||||
trainingParams.value.hitReq
|
||||
);
|
||||
const stabilityTimeLimit = getPracticeNumber(
|
||||
practiceInfo.value.timeLimit,
|
||||
trainingParams.value.timeLimit
|
||||
);
|
||||
|
||||
return {
|
||||
inline: true,
|
||||
details: [
|
||||
{ text: stabilityTimeLimit > 0 ? "在" : "" },
|
||||
{
|
||||
text: stabilityTimeLimit > 0 ? `${stabilityTimeLimit}秒` : "不限时",
|
||||
highlight: true,
|
||||
},
|
||||
{ text: stabilityTimeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
|
||||
{ text: `${hitReq}环`, highlight: true },
|
||||
{ text: "可获得能量,计时结束能量需要大于" },
|
||||
{
|
||||
text: `${stabilityEnergyReqPercent.value}%`,
|
||||
highlight: true,
|
||||
},
|
||||
{ text: "每射一箭" },
|
||||
{ text: `≥${hitReq}`, highlight: true },
|
||||
{ text: "环可为能量槽充能,充满即过关" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -555,6 +567,7 @@ const practiceInfoFields = [
|
||||
"blocks",
|
||||
"randomBlock",
|
||||
"randomRingArea",
|
||||
"randomRingAreas",
|
||||
"roundTime",
|
||||
"shootTime",
|
||||
"shootWindowStart",
|
||||
@@ -681,31 +694,14 @@ const cacheRhythmTrainingConfig = (message = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const cacheRhythmTargetArrows = (message = {}) => {
|
||||
const messageTrainingType = String(
|
||||
message.trainingType ?? message.training_type ?? trainingType.value
|
||||
).trim();
|
||||
if (messageTrainingType !== "rhythm") return;
|
||||
|
||||
const totalArrows = getPositiveInteger(
|
||||
message.totalArrows ?? message.total_arrows
|
||||
);
|
||||
if (totalArrows > 0) {
|
||||
rhythmTargetArrows.value = totalArrows;
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
|
||||
if (rhythmTargetArrows.value === 0) {
|
||||
rhythmTargetArrows.value = getPositiveInteger(
|
||||
message.arrowsLeft ?? message.arrows_left
|
||||
);
|
||||
}
|
||||
const onRhythmRoundComplete = () => {
|
||||
if (!isShootingStage.value || !start.value || !isRhythmTraining.value || practiceEnded.value) return;
|
||||
const totalRounds = getPositiveInteger(total.value);
|
||||
rhythmCompletedRounds.value = Math.min(totalRounds, rhythmCompletedRounds.value + 1);
|
||||
};
|
||||
|
||||
const syncPracticeInfo = (message = {}) => {
|
||||
cacheRhythmTrainingConfig(message);
|
||||
cacheRhythmTargetArrows(message);
|
||||
const messageTrainingType = String(
|
||||
message.trainingType ?? message.training_type ?? trainingType.value
|
||||
).trim();
|
||||
@@ -716,6 +712,22 @@ const syncPracticeInfo = (message = {}) => {
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const isRhythmSnapshot =
|
||||
messageTrainingType === "rhythm" &&
|
||||
(message.type === undefined ||
|
||||
[
|
||||
MESSAGETYPESV2.BattleStart,
|
||||
MESSAGETYPESV2.ShootResult,
|
||||
MESSAGETYPESV2.BattleEnd,
|
||||
].includes(message.type));
|
||||
|
||||
if (isRhythmSnapshot) {
|
||||
// 达标箭数的 0 按 protobuf 默认值处理;剩余数缺失不能用于推算节奏进度。
|
||||
if (!Object.prototype.hasOwnProperty.call(nextInfo, "qualifiedArrows")) {
|
||||
nextInfo.qualifiedArrows = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const isStabilityEnergySnapshot =
|
||||
messageTrainingType === "stability" &&
|
||||
(Object.prototype.hasOwnProperty.call(message, "currentEnergy") ||
|
||||
@@ -746,6 +758,9 @@ const syncPracticeInfo = (message = {}) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
|
||||
nextInfo.randomRingArea = 0;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(message, "randomRingAreas")) {
|
||||
nextInfo.randomRingAreas = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextInfo).length === 0) return;
|
||||
@@ -836,6 +851,12 @@ const playAudioKeysAndWait = (keys) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearStabilityEnergyIntroTimer = () => {
|
||||
if (!stabilityEnergyIntroTimer) return;
|
||||
clearTimeout(stabilityEnergyIntroTimer);
|
||||
stabilityEnergyIntroTimer = null;
|
||||
};
|
||||
|
||||
const shouldWaitForShotEffect = (shot) => {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
@@ -1290,7 +1311,7 @@ const clearHighlightTestTimer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
|
||||
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域同时高亮两个环。
|
||||
const runHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = true;
|
||||
@@ -1302,7 +1323,7 @@ const runHighlightTest = () => {
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: block,
|
||||
randomRingArea: 0,
|
||||
randomRingAreas: [],
|
||||
};
|
||||
|
||||
highlightTestTimer.value = setInterval(() => {
|
||||
@@ -1315,7 +1336,10 @@ const runHighlightTest = () => {
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: block,
|
||||
randomRingArea: block % 2 === 0 ? Math.min(block, 10) : 0,
|
||||
randomRingAreas:
|
||||
block % 2 === 0
|
||||
? [Math.min(block, 9), Math.min(block + 1, 10)]
|
||||
: [],
|
||||
};
|
||||
}, 1000);
|
||||
};
|
||||
@@ -1326,7 +1350,7 @@ const resetHighlightTest = () => {
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: 1,
|
||||
randomRingArea: 0,
|
||||
randomRingAreas: [],
|
||||
};
|
||||
scores.value = [];
|
||||
};
|
||||
@@ -1375,11 +1399,14 @@ onLoad((options = {}) => {
|
||||
toRouteNumber(trainingContext.scoreSlot)
|
||||
),
|
||||
};
|
||||
// 稳定训练在测距阶段预挂载 Canvas,并提前绘制 100% 满能量首帧。
|
||||
stabilityEnergyIntroPending.value =
|
||||
trainingParams.value.type === "stability";
|
||||
practiseId.value = trainingContext.practiceId || "";
|
||||
serverAddr.value = trainingContext.serverAddr || "";
|
||||
|
||||
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
|
||||
const warmupKeys = [startAudioKey, "胜利", "失败"];
|
||||
const warmupKeys = [startAudioKey, "通关成功", "通关失败", "胜利", "失败"];
|
||||
if (trainingParams.value.type === "precision") {
|
||||
warmupKeys.push("Bingo命中目标", "未命中");
|
||||
} else if (trainingParams.value.type === "rhythm") {
|
||||
@@ -1409,6 +1436,7 @@ const onReady = async () => {
|
||||
}
|
||||
|
||||
practiceStarting.value = true;
|
||||
rhythmCompletedRounds.value = 0;
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
@@ -1420,15 +1448,30 @@ const onReady = async () => {
|
||||
scores.value = [];
|
||||
shotEffectToken.value = 0;
|
||||
initializeRhythmFirstRoundCountdown();
|
||||
const shouldPlayStabilityEnergyIntro =
|
||||
trainingType.value === "stability";
|
||||
clearStabilityEnergyIntroTimer();
|
||||
const stabilityIntroGeneration = ++stabilityEnergyIntroGeneration;
|
||||
stabilityEnergyIntroPending.value = shouldPlayStabilityEnergyIntro;
|
||||
const startAudioKey = getTrainingStartAudioKey(
|
||||
trainingType.value,
|
||||
"练习开始"
|
||||
);
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
setPracticeAppHideResumable(true);
|
||||
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
|
||||
requestPracticeInfoSync();
|
||||
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
|
||||
audioManager.play(
|
||||
getTrainingStartAudioKey(trainingType.value, "练习开始")
|
||||
);
|
||||
// 开始语音只进入原播放队列,不参与能量条的入场计时。
|
||||
audioManager.play(startAudioKey);
|
||||
if (shouldPlayStabilityEnergyIntro) {
|
||||
stabilityEnergyIntroTimer = setTimeout(() => {
|
||||
stabilityEnergyIntroTimer = null;
|
||||
if (stabilityIntroGeneration !== stabilityEnergyIntroGeneration) return;
|
||||
if (!isShootingStage.value || !isStabilityTraining.value) return;
|
||||
stabilityEnergyIntroPending.value = false;
|
||||
}, STABILITY_ENERGY_ENTRY_DELAY_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
start.value = false;
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
@@ -1619,6 +1662,9 @@ function onComplete() {
|
||||
|
||||
async function onRetry() {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearStabilityEnergyIntroTimer();
|
||||
stabilityEnergyIntroGeneration += 1;
|
||||
stabilityEnergyIntroPending.value = isStabilityTraining.value;
|
||||
setPracticeAppHideResumable(false);
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
@@ -1632,7 +1678,7 @@ async function onRetry() {
|
||||
rhythmFallbackWindowStart.value = 0;
|
||||
rhythmFallbackTimestamp.value = 0;
|
||||
rhythmHasActiveServerAnchor.value = false;
|
||||
rhythmTargetArrows.value = 0;
|
||||
rhythmCompletedRounds.value = 0;
|
||||
invalidateShotPresentations({ resetVisible: true });
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
@@ -1655,7 +1701,7 @@ function onAudioEnded(key) {
|
||||
Array.from(audioWaiters).forEach((waiter) => {
|
||||
if (waiter.expectedKey === key) waiter.done();
|
||||
});
|
||||
if (["比赛结束", "练习结束"].includes(String(key || ""))) {
|
||||
if (["比赛结束", "练习结束", "通关成功", "通关失败"].includes(String(key || ""))) {
|
||||
void onOver();
|
||||
}
|
||||
}
|
||||
@@ -1731,6 +1777,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearStabilityEnergyIntroTimer();
|
||||
stabilityEnergyIntroGeneration += 1;
|
||||
stabilityEnergyIntroPending.value = false;
|
||||
setPracticeAppHideResumable(false);
|
||||
invalidateShotPresentations();
|
||||
clearPracticeRuntimeContext();
|
||||
@@ -1790,8 +1839,8 @@ onBeforeUnmount(() => {
|
||||
:inShootWindow="rhythmInShootWindow"
|
||||
:serverTimestamp="rhythmCountdownTimestamp"
|
||||
:hitReq="rhythmHitReq"
|
||||
:energyPercent="stabilityEnergyPercent"
|
||||
:energyReqPercent="stabilityEnergyReqPercent"
|
||||
:energyCostPerSec="stabilityEnergyCostPerSec"
|
||||
:scoreSlot="stabilityScoreSlot"
|
||||
:isVip="isVip"
|
||||
:isSvip="isSvip"
|
||||
:externalShootResultAudio="
|
||||
@@ -1800,6 +1849,7 @@ onBeforeUnmount(() => {
|
||||
trainingType === 'stability'
|
||||
"
|
||||
:onStop="onTimeLimitReached"
|
||||
@rhythm-round-complete="onRhythmRoundComplete"
|
||||
/>
|
||||
<view class="user-row">
|
||||
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
||||
@@ -1819,11 +1869,16 @@ onBeforeUnmount(() => {
|
||||
:showCrosshair="false"
|
||||
:sectorCount="precisionBlocks"
|
||||
:activeSector="precisionRandomBlock"
|
||||
:activeRing="precisionRandomRingArea"
|
||||
:activeRings="precisionRandomRingAreas"
|
||||
:highlightRefreshToken="precisionTargetRefreshToken"
|
||||
stable-shot-effect
|
||||
@shot-effect-complete="onShotEffectComplete"
|
||||
/>
|
||||
<StabilityEnergyTrack
|
||||
v-if="isStabilityTraining"
|
||||
:percent="stabilityEnergyPercent"
|
||||
:introPending="stabilityEnergyIntroPending"
|
||||
/>
|
||||
<!-- <view v-if="env !== 'release'" class="highlight-test-actions">
|
||||
<button
|
||||
class="highlight-test-btn"
|
||||
@@ -2019,6 +2074,8 @@ onBeforeUnmount(() => {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.sound-btn::after {
|
||||
@@ -2068,7 +2125,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
line-height: 40rpx;
|
||||
white-space: normal;
|
||||
white-space: pre-line;
|
||||
word-break: break-all;
|
||||
}
|
||||
.text1{
|
||||
@@ -2084,9 +2141,7 @@ onBeforeUnmount(() => {
|
||||
line-height: 36rpx;
|
||||
}
|
||||
.text2-yellow{
|
||||
font-size: 30rpx;
|
||||
color: #FFD947;
|
||||
font-weight: 500;
|
||||
margin: 0 4rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { getSelectedExperienceStore, hasAnyStoreVip } from "@/utils/storeVip";
|
||||
import Container from "@/components/Container.vue";
|
||||
import UserHeader from "@/components/UserHeader.vue";
|
||||
import UserItem from "@/components/UserItem.vue";
|
||||
@@ -26,20 +28,40 @@ const toFristTryPage = async () => {
|
||||
}
|
||||
};
|
||||
const toCoinPage = () => {
|
||||
const selected = getSelectedExperienceStore();
|
||||
uni.navigateTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
url: selected ? "/pages/coin/index?tab=vip" : "/pages/coin/nearby-stores?tab=vip",
|
||||
});
|
||||
};
|
||||
const showCoinEntry = ref(false);
|
||||
const storeVipLabel = ref("加载中");
|
||||
const storeVipFailed = ref(false);
|
||||
let entryRequest = 0;
|
||||
const loadCoinEntryVisibility = async () => {
|
||||
const requestId = ++entryRequest;
|
||||
showCoinEntry.value = false;
|
||||
storeVipFailed.value = false;
|
||||
storeVipLabel.value = "加载中";
|
||||
try {
|
||||
const result = await getMyGoldAPI();
|
||||
if (requestId !== entryRequest) return;
|
||||
showCoinEntry.value = result?.hasTempBind === true;
|
||||
if (!showCoinEntry.value) return;
|
||||
try {
|
||||
const active = await hasAnyStoreVip();
|
||||
if (requestId !== entryRequest) return;
|
||||
storeVipLabel.value = active ? "VIP已开通" : "VIP未开通";
|
||||
} catch (error) {
|
||||
if (requestId !== entryRequest) return;
|
||||
storeVipLabel.value = "加载失败,重试";
|
||||
storeVipFailed.value = true;
|
||||
console.error("加载入口门店VIP失败", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载金币入口状态失败", error);
|
||||
}
|
||||
};
|
||||
onShow(loadCoinEntryVisibility);
|
||||
const toBeVipPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
@@ -85,7 +107,6 @@ const logout = () => {
|
||||
updateUser();
|
||||
};
|
||||
onMounted(() => {
|
||||
loadCoinEntryVisibility();
|
||||
const accountInfo = uni.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
if (envVersion !== "release") showLogout.value = true;
|
||||
@@ -127,10 +148,12 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
||||
</UserItem>
|
||||
<UserItem
|
||||
v-if="showCoinEntry"
|
||||
title="我的金币"
|
||||
title="线下体验中心"
|
||||
:onClick="toCoinPage"
|
||||
/>
|
||||
<UserItem title="会员" :onClick="toBeVipPage">
|
||||
>
|
||||
<text class="store-vip-label" @click.stop="storeVipFailed ? loadCoinEntryVisibility() : toCoinPage()">{{ storeVipLabel }}</text>
|
||||
</UserItem>
|
||||
<!-- <UserItem title="会员" :onClick="toBeVipPage">
|
||||
<view
|
||||
v-if="user.sVip === true"
|
||||
class="member-nickname member-nickname--svip"
|
||||
@@ -145,7 +168,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
||||
<text class="member-nickname__text">已开通VIP</text>
|
||||
</view>
|
||||
<text v-else>未开通</text>
|
||||
</UserItem>
|
||||
</UserItem> -->
|
||||
<UserItem title="等级介绍" :onClick="toGradeIntroPage">
|
||||
<text :style="{ color: '#4C76FF' }">Lv{{ user.lvl }}</text>
|
||||
<text
|
||||
@@ -205,6 +228,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-vip-label { padding: 4rpx 12rpx; border: 1rpx solid #999999; border-radius: 20rpx; font-size: 24rpx; }
|
||||
.container {
|
||||
background-color: #f5f5f5;
|
||||
height: calc(100% - 65px);
|
||||
|
||||
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
@@ -27,6 +27,11 @@ const getDefaultDeviceStatus = () => ({
|
||||
receivedAt: 0,
|
||||
});
|
||||
|
||||
const getDefaultDeviceUsageDuration = () => ({
|
||||
deviceId: "",
|
||||
seconds: null,
|
||||
});
|
||||
|
||||
const getDefaultGame = () => ({
|
||||
roomID: "",
|
||||
inBattle: false,
|
||||
@@ -114,6 +119,8 @@ export default defineStore("store", {
|
||||
deviceBattery: null,
|
||||
// WebSocket 实时推送的设备状态,不参与持久化。
|
||||
deviceStatus: getDefaultDeviceStatus(),
|
||||
// 按设备记录本次运行期间已知的最大累计使用时长,离线时保持数值不回退。
|
||||
deviceUsageDuration: getDefaultDeviceUsageDuration(),
|
||||
game: {
|
||||
roomID: "",
|
||||
inBattle: false,
|
||||
@@ -183,6 +190,27 @@ export default defineStore("store", {
|
||||
this.deviceStatus = nextStatus;
|
||||
this.online = online;
|
||||
this.deviceBattery = online ? nextStatus.battery : null;
|
||||
this.updateDeviceUsageDuration(nextStatus.onlineDuration);
|
||||
},
|
||||
updateDeviceUsageDuration(value, deviceId = this.device.deviceId) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") return;
|
||||
|
||||
const seconds = Number(value);
|
||||
const normalizedDeviceId = String(deviceId || "");
|
||||
if (!normalizedDeviceId || !Number.isFinite(seconds) || seconds < 0) return;
|
||||
|
||||
const cachedDeviceId = String(this.deviceUsageDuration.deviceId || "");
|
||||
const cachedSeconds = Number(this.deviceUsageDuration.seconds);
|
||||
this.deviceUsageDuration = {
|
||||
deviceId: normalizedDeviceId,
|
||||
seconds:
|
||||
cachedDeviceId === normalizedDeviceId && Number.isFinite(cachedSeconds)
|
||||
? Math.max(cachedSeconds, seconds)
|
||||
: seconds,
|
||||
};
|
||||
},
|
||||
clearDeviceUsageDuration() {
|
||||
this.deviceUsageDuration = getDefaultDeviceUsageDuration();
|
||||
},
|
||||
setDeviceOnline(online) {
|
||||
const nextOnline = online === true;
|
||||
@@ -210,6 +238,7 @@ export default defineStore("store", {
|
||||
updateDevice(deviceId, deviceName) {
|
||||
if (String(this.device.deviceId || "") !== String(deviceId || "")) {
|
||||
this.clearDeviceStatus();
|
||||
this.clearDeviceUsageDuration();
|
||||
}
|
||||
this.device.deviceId = deviceId;
|
||||
this.device.deviceName = deviceName;
|
||||
@@ -217,6 +246,7 @@ export default defineStore("store", {
|
||||
clearDevice() {
|
||||
this.device = getDefaultDevice();
|
||||
this.clearDeviceStatus();
|
||||
this.clearDeviceUsageDuration();
|
||||
},
|
||||
async updateConfig(config) {
|
||||
this.config = config;
|
||||
@@ -267,6 +297,7 @@ export default defineStore("store", {
|
||||
online: false,
|
||||
deviceBattery: null,
|
||||
deviceStatus: getDefaultDeviceStatus(),
|
||||
deviceUsageDuration: getDefaultDeviceUsageDuration(),
|
||||
game: getDefaultGame(),
|
||||
dailyCount: getDefaultDailyCount(),
|
||||
deviceChargingDialogVisible: false,
|
||||
|
||||
@@ -2,13 +2,23 @@ import protobuf from "protobufjs/minimal.js";
|
||||
|
||||
const { Reader, Writer } = protobuf;
|
||||
|
||||
// protobuf 会将这些 repeated 标量默认编码为 packed(length-delimited),
|
||||
// 同时也允许发送端使用逐项的 unpacked 编码。
|
||||
const PACKABLE_SCALAR_KINDS = new Set([
|
||||
"int32",
|
||||
"int64",
|
||||
"float",
|
||||
"double",
|
||||
"bool",
|
||||
]);
|
||||
|
||||
// 比赛服 protobuf 协议适配层:
|
||||
// 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen,
|
||||
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
|
||||
// <match-schema-generated>
|
||||
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
|
||||
// 来源:src/utils/match.min.js(sha256: ec0371baeba9a9bf)
|
||||
// 协议命名空间:rpc;消息数:12;字段数:163
|
||||
// 来源:src/utils/match.min.js(sha256: 5b72fd5f9e087921)
|
||||
// 协议命名空间:rpc;消息数:12;字段数:164
|
||||
|
||||
export const ServerMessageType = {
|
||||
SERVER_MSG_UNKNOWN: 0,
|
||||
@@ -206,6 +216,7 @@ const SCHEMAS = {
|
||||
61: { name: "delta_qualified_rate", kind: "int32" },
|
||||
62: { name: "hit_rate", kind: "int32" },
|
||||
63: { name: "delta_hit_rate", kind: "int32" },
|
||||
64: { name: "random_ring_areas", kind: "int32", repeated: true },
|
||||
},
|
||||
MatchInfo: {
|
||||
1: { name: "match_id", kind: "string" },
|
||||
@@ -341,6 +352,20 @@ function readValue(reader, field) {
|
||||
return readScalar(reader, field.kind);
|
||||
}
|
||||
|
||||
function readPackedValues(reader, field) {
|
||||
const end = reader.uint32() + reader.pos;
|
||||
const values = [];
|
||||
|
||||
while (reader.pos < end) {
|
||||
values.push(readScalar(reader, field.kind));
|
||||
}
|
||||
if (reader.pos !== end) {
|
||||
throw new Error(`Invalid packed protobuf field: ${field.name}`);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeMessage(schemaName, readerOrData, length) {
|
||||
const schema = SCHEMAS[schemaName];
|
||||
const reader =
|
||||
@@ -351,9 +376,10 @@ function decodeMessage(schemaName, readerOrData, length) {
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
const field = schema[tag >>> 3];
|
||||
const wireType = tag & 7;
|
||||
|
||||
if (!field) {
|
||||
reader.skipType(tag & 7);
|
||||
reader.skipType(wireType);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -365,6 +391,17 @@ function decodeMessage(schemaName, readerOrData, length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
field.repeated &&
|
||||
PACKABLE_SCALAR_KINDS.has(field.kind) &&
|
||||
wireType === 2
|
||||
) {
|
||||
const values = message[field.name] || [];
|
||||
values.push(...readPackedValues(reader, field));
|
||||
message[field.name] = values;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = readValue(reader, field);
|
||||
|
||||
if (field.repeated) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getStoreBenefitAPI, getStoreVipListAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
|
||||
// 按账号隔离最近选择,刷新登录令牌后仍可恢复同一门店。
|
||||
const selectionKey = () => {
|
||||
const version = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
return `${version}_experience_store_${useStore().user.id || "guest"}`;
|
||||
};
|
||||
|
||||
export const getSelectedExperienceStore = () => {
|
||||
const value = uni.getStorageSync(selectionKey());
|
||||
return value && /^\d+$/.test(String(value.id)) && Number(value.id) > 0
|
||||
? { id: String(value.id), name: String(value.name || "") }
|
||||
: null;
|
||||
};
|
||||
|
||||
export const rememberExperienceStore = (id, name) => {
|
||||
uni.setStorageSync(selectionKey(), { id: String(id), name });
|
||||
};
|
||||
|
||||
// 入口汇总所有门店;找到有效会员即可结束,未开通须查询完整列表。
|
||||
export const hasAnyStoreVip = async () => {
|
||||
const pageSize = 20;
|
||||
let received = 0;
|
||||
for (let page = 1; ; page += 1) {
|
||||
const result = await getStoreVipListAPI({ page, pageSize });
|
||||
const list = result?.list;
|
||||
if (!Array.isArray(list) || !Number.isSafeInteger(result.total) || result.total < 0) {
|
||||
throw new Error("门店会员列表响应格式异常");
|
||||
}
|
||||
if (list.some((item) => item?.isVip === true)) return true;
|
||||
if (list.some((item) => typeof item?.isVip !== "boolean")) {
|
||||
throw new Error("门店会员状态缺失");
|
||||
}
|
||||
received += list.length;
|
||||
if (received >= result.total) return false;
|
||||
// 列表尚未读完却返回空页时,不把不完整结果判为未开通。
|
||||
if (!list.length) throw new Error("门店会员列表分页异常");
|
||||
}
|
||||
};
|
||||
|
||||
// 只接受非负整数,缺失或异常值不能转换成“已用完”的 0 次。
|
||||
const normalizeRemaining = (benefit) => {
|
||||
const remain = benefit?.remain;
|
||||
return Number.isSafeInteger(remain) && remain >= 0 ? remain : "--";
|
||||
};
|
||||
|
||||
export const normalizeStoreMembership = (store) => {
|
||||
if (typeof store?.isVip !== "boolean") throw new Error("门店会员状态缺失");
|
||||
const active = store.isVip === true;
|
||||
return {
|
||||
active,
|
||||
cardImage: `https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-card-${active ? "active" : "inactive"}.png`,
|
||||
expiresAt: active ? String(store.vipExpireAt || "").slice(0, 10).replace(/-/g, "/") || "--" : "",
|
||||
// 未开通或已过期统一为 0;有效会员逐项读取后端剩余次数。
|
||||
remaining: {
|
||||
trial: active ? normalizeRemaining(store.freeTrial) : 0,
|
||||
discount: active ? normalizeRemaining(store.discountPurchase) : 0,
|
||||
drink: active ? normalizeRemaining(store.drink) : 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const loadStoreMembership = async (storeId) => {
|
||||
const result = await getStoreBenefitAPI(storeId);
|
||||
if (String(result?.storeId) !== String(storeId)) {
|
||||
throw new Error("门店权益响应与当前门店不匹配");
|
||||
}
|
||||
return { name: result.storeName || "", ...normalizeStoreMembership(result) };
|
||||
};
|
||||
@@ -93,30 +93,36 @@ function createWebSocket(token, onMessage) {
|
||||
"/addons/shoot/otaResult",
|
||||
];
|
||||
if (passthroughEvents.includes(event)) {
|
||||
if ((code == null || Number(code) === 0) && onMessage && data && typeof data === "object") {
|
||||
if (
|
||||
(code == null || Number(code) === 0) &&
|
||||
onMessage &&
|
||||
data &&
|
||||
typeof data === "object"
|
||||
) {
|
||||
onMessage({ event, data, code, timestamp });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data?.type) {
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.type) {
|
||||
if (ENABLE_REALTIME_MESSAGE_LOG) {
|
||||
console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
|
||||
}
|
||||
if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
|
||||
return;
|
||||
}
|
||||
const updates = Array.isArray(data?.updates) ? data.updates : [];
|
||||
if (!updates.length) return;
|
||||
if (onMessage) onMessage(updates);
|
||||
const msg = updates[0];
|
||||
if (msg) {
|
||||
if (ENABLE_REALTIME_MESSAGE_LOG) {
|
||||
if (!Array.isArray(data.updates)) return;
|
||||
if (onMessage) onMessage(data.updates);
|
||||
const firstMessage = data.updates[0];
|
||||
if (firstMessage && ENABLE_REALTIME_MESSAGE_LOG) {
|
||||
console.log(
|
||||
"收到 WebSocket 更新",
|
||||
getMessageTypeName(msg.constructor),
|
||||
getMessageTypeName(firstMessage.constructor),
|
||||
{ updateCount: data.updates.length }
|
||||
);
|
||||
}
|
||||
data.updates.forEach((msg) => {
|
||||
if (msg) {
|
||||
if (msg.constructor === MESSAGETYPES.RankUpdate) {
|
||||
uni.setStorageSync("latestRank", msg.lvl);
|
||||
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
|
||||
@@ -130,6 +136,7 @@ function createWebSocket(token, onMessage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socketTask.onError((err) => {
|
||||
if (socket !== socketTask) return;
|
||||
|
||||