Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e0307854 | ||
|
|
0a678cb387 | ||
|
|
3c7467fbdf | ||
|
|
6aa3c6fd6c | ||
|
|
8b11e41957 | ||
|
|
8c6374b2c5 | ||
|
|
78a495de60 | ||
|
|
ef13ff6a2b | ||
|
|
8b7a94b8c4 | ||
|
|
3e99bb8ad9 | ||
|
|
40b94dbad8 | ||
|
|
422a93dbe2 | ||
|
|
5c74edf001 | ||
|
|
937308e51d |
+36
@@ -25,6 +25,7 @@ try {
|
||||
|
||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||
|
||||
// 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
@@ -39,6 +40,10 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
data,
|
||||
timeout: 10000,
|
||||
success: (res) => {
|
||||
if (url === "/user/hardwareBox/connectWifi" && res.statusCode === 200 && res.data && Object.keys(res.data).length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (res.data) {
|
||||
const {code, data, message} = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
@@ -361,6 +366,17 @@ export const createOrderAPI = (vipId) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const virtualPayOrderAPI = (vipId = 0, code = "") => {
|
||||
return request("POST", "/user/virtualPay/createOrder", {
|
||||
vipId,
|
||||
code,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrderDetailAPI = (orderId) => {
|
||||
return request("GET", `/user/order/detail?orderId=${encodeURIComponent(orderId)}`);
|
||||
};
|
||||
|
||||
export const payOrderAPI = (id) => {
|
||||
return request("POST", "/user/order/pay", {
|
||||
id,
|
||||
@@ -473,6 +489,26 @@ export const getDeviceBatteryAPI = async () => {
|
||||
return request("GET", "/user/device/battery");
|
||||
};
|
||||
|
||||
// 设备连接指定 WiFi,只下发 WiFi 凭证,不触发 OTA 升级。
|
||||
export const connectDeviceWifiAPI = async (ssid, password) => {
|
||||
return request("POST", "/user/hardwareBox/connectWifi", {ssid, password});
|
||||
};
|
||||
|
||||
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
||||
export const getHardwareBoxVersionAPI = async () => {
|
||||
return request("GET", "/user/hardwareBox/version");
|
||||
};
|
||||
|
||||
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||
export const sendHardwareBoxUpdateAPI = async (data) => {
|
||||
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
||||
};
|
||||
|
||||
// 根据任务 ID 获取硬件盒子 OTA 更新状态。
|
||||
export const getHardwareBoxTaskStatusAPI = async (taskId) => {
|
||||
return request("GET", `/user/hardwareBox/taskStatus?taskId=${taskId}`);
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
return request("POST", "/user/score/sheet/remark", {id, remark});
|
||||
};
|
||||
|
||||
@@ -40,6 +40,8 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya59b6pu0ur4um.mp3",
|
||||
比赛开始:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcuu5z3a3lumkutske.mp3",
|
||||
下半场开始:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E4%B8%8B%E5%8D%8A%E5%9C%BA%E5%BC%80%E5%A7%8B.mp3",
|
||||
请开始射击:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
|
||||
射击无效:
|
||||
|
||||
@@ -207,7 +207,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
justify-content: center;
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
padding-top: 7px;
|
||||
/* padding-top: 7px; */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.player-name {
|
||||
|
||||
@@ -34,6 +34,7 @@ const phase = ref("idle");
|
||||
const activePlayKey = ref("");
|
||||
const animationKey = ref("");
|
||||
const impactEmitted = ref(false);
|
||||
const activeShot = ref(null);
|
||||
let timers = [];
|
||||
|
||||
const isActive = computed(() => phase.value !== "idle");
|
||||
@@ -54,9 +55,17 @@ const safeTargetSize = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function hasShotPoint(shot) {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
return Number.isFinite(x) && Number.isFinite(y);
|
||||
}
|
||||
|
||||
const effectiveShot = computed(() => activeShot.value || props.shot);
|
||||
|
||||
const shotPoint = computed(() => {
|
||||
const x = Number(props.shot?.x);
|
||||
const y = Number(props.shot?.y);
|
||||
const x = Number(effectiveShot.value?.x);
|
||||
const y = Number(effectiveShot.value?.y);
|
||||
return {
|
||||
x: Number.isFinite(x) ? x : 0,
|
||||
y: Number.isFinite(y) ? y : 0,
|
||||
@@ -179,16 +188,20 @@ function finish(playKey) {
|
||||
clearTimers();
|
||||
phase.value = "idle";
|
||||
activePlayKey.value = "";
|
||||
activeShot.value = null;
|
||||
emit("complete", playKey);
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!props.playKey || !props.shot || !props.shot.ring) return;
|
||||
if (!props.playKey || !props.shot || !props.shot.ring || !hasShotPoint(props.shot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
activePlayKey.value = props.playKey;
|
||||
animationKey.value = `${props.playKey}`;
|
||||
impactEmitted.value = false;
|
||||
activeShot.value = { ...props.shot };
|
||||
phase.value = "playing";
|
||||
|
||||
queueTimer(() => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
nextTick,
|
||||
getCurrentInstance,
|
||||
} from "vue";
|
||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||
import BowShotEffect from "@/components/BowShotEffect.vue";
|
||||
|
||||
@@ -56,8 +64,6 @@ const props = defineProps({
|
||||
const pMode = ref(true);
|
||||
const latestOne = ref(null);
|
||||
const bluelatestOne = ref(null);
|
||||
const prevScores = ref([]);
|
||||
const prevBlueScores = ref([]);
|
||||
const timer = ref(null);
|
||||
const dirTimer = ref(null);
|
||||
const angle = ref(null);
|
||||
@@ -66,7 +72,9 @@ const shotEffect = ref(null);
|
||||
const hiddenRedLatestKey = ref("");
|
||||
const hiddenBlueLatestKey = ref("");
|
||||
const targetShaking = ref(false);
|
||||
const targetSize = ref({ width: 0, height: 0 });
|
||||
const shakeTimer = ref(null);
|
||||
const instance = getCurrentInstance();
|
||||
const ROUND_TIP_OFFSET_Y = -32;
|
||||
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||
|
||||
@@ -82,8 +90,14 @@ function buildShotEffectKey(team, shot, index) {
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function hasShotPoint(shot) {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
return Number.isFinite(x) && Number.isFinite(y);
|
||||
}
|
||||
|
||||
function shouldPlayShotEffect(shot) {
|
||||
return props.isSvip && !!shot && Number(shot.ring) > 0;
|
||||
return props.isSvip && !!shot && Number(shot.ring) > 0 && hasShotPoint(shot);
|
||||
}
|
||||
|
||||
function clearTipTimer() {
|
||||
@@ -154,6 +168,29 @@ function shakeTarget() {
|
||||
});
|
||||
}
|
||||
|
||||
function updateTargetSize() {
|
||||
nextTick(() => {
|
||||
const query = instance?.proxy
|
||||
? uni.createSelectorQuery().in(instance.proxy)
|
||||
: uni.createSelectorQuery();
|
||||
|
||||
query
|
||||
.select(".target")
|
||||
.boundingClientRect((rect) => {
|
||||
const width = Number(rect?.width);
|
||||
const height = Number(rect?.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
targetSize.value = { width, height };
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
updateTargetSize();
|
||||
}
|
||||
|
||||
function shouldHideRedHit(index) {
|
||||
return !!hiddenRedLatestKey.value && index === props.scores.length - 1;
|
||||
}
|
||||
@@ -163,46 +200,44 @@ function shouldHideBlueHit(index) {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevScores.value.length === 1) {
|
||||
const latestShot = newVal[newVal.length - 1];
|
||||
() => props.scores.length,
|
||||
(newLen, oldLen) => {
|
||||
if (newLen === oldLen + 1) {
|
||||
const latestShot = props.scores[newLen - 1];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
triggerShotEffect("red", latestShot, newVal.length - 1);
|
||||
triggerShotEffect("red", latestShot, newLen - 1);
|
||||
} else {
|
||||
showShotTip("red", latestShot);
|
||||
}
|
||||
} else if (newVal.length <= prevScores.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLen < oldLen) {
|
||||
latestOne.value = null;
|
||||
hiddenRedLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "red") shotEffect.value = null;
|
||||
}
|
||||
prevScores.value = [...newVal];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.blueScores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevBlueScores.value.length === 1) {
|
||||
const latestShot = newVal[newVal.length - 1];
|
||||
() => props.blueScores.length,
|
||||
(newLen, oldLen) => {
|
||||
if (newLen === oldLen + 1) {
|
||||
const latestShot = props.blueScores[newLen - 1];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
triggerShotEffect("blue", latestShot, newVal.length - 1);
|
||||
triggerShotEffect("blue", latestShot, newLen - 1);
|
||||
} else {
|
||||
showShotTip("blue", latestShot);
|
||||
}
|
||||
} else if (newVal.length <= prevBlueScores.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLen < oldLen) {
|
||||
bluelatestOne.value = null;
|
||||
hiddenBlueLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "blue") shotEffect.value = null;
|
||||
}
|
||||
prevBlueScores.value = [...newVal];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -349,6 +384,8 @@ async function onReceiveMessage(message) {
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
updateTargetSize();
|
||||
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -365,6 +402,7 @@ onBeforeUnmount(() => {
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -461,6 +499,9 @@ onBeforeUnmount(() => {
|
||||
:shot="shotEffect && shotEffect.shot"
|
||||
:playKey="shotEffect ? shotEffect.key : ''"
|
||||
:targetRadius="safeTargetRadius"
|
||||
:targetWidth="targetSize.width"
|
||||
:targetHeight="targetSize.height"
|
||||
:hitOffsetPx="currentHitRadiusPx"
|
||||
@impact="shakeTarget"
|
||||
@complete="completeShotEffect"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
|
||||
const OTA_MIN_BATTERY = 50;
|
||||
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
|
||||
const OTA_OFFLINE_TEXT = "请先开启智能弓";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -42,6 +47,32 @@ const isSuccess = computed(() => props.state === "update_success");
|
||||
const isFailure = computed(() => props.state === "update_failure");
|
||||
// Clamp progress to keep the progress bar width within its container.
|
||||
const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.progress) || 0)));
|
||||
|
||||
// 点击立即更新前先校验设备在线状态,再校验设备电量。
|
||||
const handleUpdateClick = async () => {
|
||||
try {
|
||||
const deviceStatus = await getDeviceBatteryAPI();
|
||||
if (deviceStatus?.online !== true) {
|
||||
uni.showToast({
|
||||
title: OTA_OFFLINE_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) {
|
||||
uni.showToast({
|
||||
title: OTA_LOW_BATTERY_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
emit("update");
|
||||
return;
|
||||
}
|
||||
emit("update");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +122,7 @@ const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.prog
|
||||
<block v-if="isNewVersion">
|
||||
<image src="../static/ota/new-ver.png" mode="aspectFit" class="new-ver-img" />
|
||||
<view v-if="version" class="version-tag-wrap">
|
||||
<image src="../static/ota/ota-ver.png" mode="aspectFill" class="version-tag-bg-img" />
|
||||
<image src="../static/ota/ota-ver.png" mode="aspectFit" class="version-tag-bg-img" />
|
||||
<text class="version-tag">{{ version }}</text>
|
||||
</view>
|
||||
<!-- 副标题:如“新版本将优化智能弓体验”,离下方详情 12rpx -->
|
||||
@@ -99,7 +130,7 @@ const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.prog
|
||||
<!-- 详细说明:如“升级前请确保:...” -->
|
||||
<text v-if="changelog" class="changelog-text">{{ changelog }}</text>
|
||||
<view class="btn-group">
|
||||
<view class="primary-btn" @click="emit('update')">
|
||||
<view class="primary-btn" @click="handleUpdateClick">
|
||||
<text class="primary-btn-text">立即更新</text>
|
||||
</view>
|
||||
<text v-if="!forceUpdate" class="skip-text" @click="emit('skip')">暂不更新</text>
|
||||
@@ -146,7 +177,7 @@ const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.prog
|
||||
|
||||
<!-- 关闭按钮(仅新版本状态,非强制更新时,位于弹窗下方) -->
|
||||
<view
|
||||
v-if="isNewVersion && !forceUpdate"
|
||||
v-if="(isNewVersion || isFailure) && !forceUpdate"
|
||||
class="ota-close-below"
|
||||
@click="emit('close')"
|
||||
>
|
||||
@@ -270,6 +301,8 @@ const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.prog
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 116rpx;
|
||||
height: 44rpx;
|
||||
/* 离标题图 -10rpx,左边距 50rpx,离下方副标题 22rpx */
|
||||
margin-top: -10rpx;
|
||||
margin-left: 50rpx;
|
||||
|
||||
@@ -143,7 +143,7 @@ const openCoachComment = () => {
|
||||
}}</text
|
||||
>环的成绩,所有箭支上靶后的平均点间距为<text
|
||||
:style="{ color: '#fed847' }"
|
||||
>{{ Number((result.average_distance || 0).toFixed(2)) }}</text
|
||||
>{{ Number((result?.interpretation?.spreadStability || 0).toFixed(2)) }}</text
|
||||
>,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "还需要持续改进哦~"
|
||||
|
||||
@@ -38,6 +38,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
halfRest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onStop: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
@@ -136,8 +140,9 @@ const updateSound = () => {
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
const audioKey = props.melee && (halfTime.value || props.halfRest) ? "下半场开始" : "比赛开始";
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
audioManager.play(audioKey);
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("比赛结束", false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const step = ref(0);
|
||||
const total = 12;
|
||||
const stepButtonTexts = [
|
||||
@@ -114,6 +115,7 @@ const onOver = async () => {
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
isSvip.value = msg.sVip === true;
|
||||
scores.value = msg.details;
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(onOver, 1500);
|
||||
@@ -204,6 +206,7 @@ const nextStep = async () => {
|
||||
title.value = "小试牛刀";
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
step.value = 5;
|
||||
start.value = true;
|
||||
setTimeout(() => {
|
||||
@@ -230,6 +233,7 @@ const onClose = async () => {
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
step.value = 4;
|
||||
const result = await createPractiseAPI(total, 120);
|
||||
if (result) practiseId.value = result.id;
|
||||
@@ -343,6 +347,7 @@ const onClose = async () => {
|
||||
:currentRound="step === 5 ? scores.length : 0"
|
||||
:totalRound="step === 5 ? total : 0"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
/>
|
||||
<ScorePanel
|
||||
v-if="step === 5"
|
||||
|
||||
+10
-1
@@ -198,7 +198,7 @@ const startHomeOtaUpdate = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 点击立即更新时先判断设备是否已通过 WiFi 联网,已联网则首页直接更新,否则跳转 WiFi 页面。
|
||||
// 点击立即更新时先判断设备是否在线并已通过 WiFi 联网,已联网则首页直接更新,否则跳转 WiFi 页面。
|
||||
const handleOtaUpdate = async () => {
|
||||
if (isStartingOta.value) return;
|
||||
isStartingOta.value = true;
|
||||
@@ -214,6 +214,15 @@ const handleOtaUpdate = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deviceStatus?.online !== true) {
|
||||
isStartingOta.value = false;
|
||||
uni.showToast({
|
||||
title: "请先开启智能弓",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(deviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||
startHomeOtaUpdate();
|
||||
return;
|
||||
|
||||
@@ -295,6 +295,7 @@ onShow(async () => {
|
||||
:tips="tips"
|
||||
:total="90"
|
||||
:melee="true"
|
||||
:halfRest="halfRest"
|
||||
:battleId="battleId"
|
||||
/>
|
||||
<view v-if="start" class="user-row">
|
||||
|
||||
+135
-126
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref, onBeforeUnmount } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import { createOrderAPI, getAppConfig, getHomeData } from "@/apis";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import { virtualPayOrderAPI, getAppConfig, getHomeData, getOrderDetailAPI } from "@/apis";
|
||||
import { capsuleHeight, wxLogin } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
@@ -18,11 +18,8 @@ const showModal = ref(false);
|
||||
const loadingConfig = ref(false);
|
||||
const paying = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const timer = ref(null);
|
||||
const lastDate = ref(user.value.expiredAt || 0);
|
||||
const maxRefreshTimes = 12;
|
||||
|
||||
// 会员页核心展示数据:视觉、权益、套餐均按蓝湖当前两张设计稿拆分。
|
||||
// 会员页核心展示数据:视觉、权益按蓝湖当前两张设计稿拆分,套餐完全使用接口数据。
|
||||
const memberTypes = [
|
||||
{
|
||||
key: "normal",
|
||||
@@ -44,11 +41,6 @@ const memberTypes = [
|
||||
{ label: "排位赛\n每日+20次", icon: "../../static/vip/vip-rank.png" },
|
||||
{ label: "约战\n每日+20次", icon: "../../static/vip/vip-battle.png" },
|
||||
],
|
||||
packages: [
|
||||
{ name: "连续包月", price: "20", original: "35" },
|
||||
{ name: "12个月", price: "300", original: "420" },
|
||||
{ name: "3个月", price: "84", original: "105" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "super",
|
||||
@@ -73,11 +65,6 @@ const memberTypes = [
|
||||
{ label: "排位赛无限制", icon: "../../static/vip/svip-rank.png" },
|
||||
{ label: "专享SVIP客服", icon: "../../static/vip/svip-service.png" },
|
||||
],
|
||||
packages: [
|
||||
{ name: "连续包月", price: "20", original: "35" },
|
||||
{ name: "12个月", price: "300", original: "420" },
|
||||
{ name: "3个月", price: "84", original: "105" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -100,16 +87,6 @@ const getVipExpiredValue = (type, source = user.value) => {
|
||||
return type.key === "super" ? source.superVipExpiredAt : source.normalVipExpiredAt;
|
||||
};
|
||||
|
||||
// 支付后轮询需要比较最新会员到期时间,兼容旧字段 expiredAt。
|
||||
const getLatestVipExpiredTime = (source = user.value) => {
|
||||
if (!source) return 0;
|
||||
return Math.max(
|
||||
toTimestamp(source.normalVipExpiredAt),
|
||||
toTimestamp(source.superVipExpiredAt),
|
||||
toTimestamp(source.expiredAt)
|
||||
);
|
||||
};
|
||||
|
||||
// 未过期才展示“会员生效中”样式,已过期或无值继续展示未开通样式。
|
||||
const isVipActive = (type) => {
|
||||
return toTimestamp(getVipExpiredValue(type)) > Date.now();
|
||||
@@ -153,9 +130,34 @@ const getMenuName = (item) => {
|
||||
return item.name || item.vipName || item.title || "";
|
||||
};
|
||||
|
||||
const formatPrice = (value) => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
return String(value).replace("¥", "").replace("¥", "");
|
||||
};
|
||||
|
||||
const getMenuPrice = (item) => {
|
||||
const value = item.price || item.total || item.amount || item.money;
|
||||
return value ? String(value).replace("¥", "").replace("¥", "") : "";
|
||||
return formatPrice(item && item.price);
|
||||
};
|
||||
|
||||
const getMenuOriginalPrice = (item) => {
|
||||
return formatPrice(item && item.originalPrice);
|
||||
};
|
||||
|
||||
const getMenuSortValue = (item) => {
|
||||
const value = item && item.sort;
|
||||
if (value === undefined || value === null || value === "") return Number.MAX_SAFE_INTEGER;
|
||||
const sort = Number(value);
|
||||
return Number.isFinite(sort) ? sort : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
const sortMenusBySort = (menus = []) => {
|
||||
return menus
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const sortDiff = getMenuSortValue(a.item) - getMenuSortValue(b.item);
|
||||
return sortDiff || a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
};
|
||||
|
||||
const getMenuType = (item) => {
|
||||
@@ -173,41 +175,19 @@ const getMenuType = (item) => {
|
||||
return "";
|
||||
};
|
||||
|
||||
const getPackageMonths = (name) => {
|
||||
if (/连续包月/.test(name)) return 1;
|
||||
const match = String(name || "").match(/(\d+)\s*个?月/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
|
||||
const matchPackageSource = (type, pack, index) => {
|
||||
const menus = configMenus.value;
|
||||
const typedMenus = menus.filter((item) => {
|
||||
const menuType = getMenuType(item);
|
||||
if (type.key === "super") return menuType === "super";
|
||||
return menuType === "normal";
|
||||
});
|
||||
const pool = typedMenus.length ? typedMenus : menus;
|
||||
const packMonths = getPackageMonths(pack.name);
|
||||
return (
|
||||
pool.find((item) => getMenuName(item).indexOf(pack.name) !== -1) ||
|
||||
pool.find((item) => packMonths && getPackageMonths(getMenuName(item)) === packMonths) ||
|
||||
pool.find((item) => getMenuPrice(item) === pack.price) ||
|
||||
pool[index]
|
||||
);
|
||||
};
|
||||
|
||||
const getPackages = (type) => {
|
||||
return type.packages.map((item, index) => {
|
||||
const source = matchPackageSource(type, item, index);
|
||||
return {
|
||||
...item,
|
||||
source,
|
||||
name: source ? getMenuName(source) || item.name : item.name,
|
||||
price: source ? getMenuPrice(source) || item.price : item.price,
|
||||
icon: source && source.icon,
|
||||
id: source && source.id,
|
||||
};
|
||||
});
|
||||
return sortMenusBySort(configMenus.value)
|
||||
.filter((item) => getMenuType(item) === type.key)
|
||||
.map((item) => {
|
||||
return {
|
||||
source: item,
|
||||
id: item.id,
|
||||
name: getMenuName(item),
|
||||
price: getMenuPrice(item),
|
||||
originalPrice: getMenuOriginalPrice(item),
|
||||
icon: item.icon,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const currentPackages = computed(() => {
|
||||
@@ -263,41 +243,38 @@ const loadVipConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearRefreshTimer = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
const refreshUserAfterPay = async () => {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const result = await getHomeData();
|
||||
if (result.user) {
|
||||
updateUser(result.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("refresh user after pay error", error);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUserAfterPay = () => {
|
||||
clearRefreshTimer();
|
||||
let refreshTimes = 0;
|
||||
// 先记录支付前的最大到期时间,轮询到更大的值就认为会员状态已刷新。
|
||||
lastDate.value = getLatestVipExpiredTime();
|
||||
refreshing.value = true;
|
||||
timer.value = setInterval(async () => {
|
||||
refreshTimes += 1;
|
||||
try {
|
||||
const result = await getHomeData();
|
||||
const latestExpiredAt = getLatestVipExpiredTime(result.user);
|
||||
if (result.user && latestExpiredAt > lastDate.value) {
|
||||
lastDate.value = latestExpiredAt;
|
||||
updateUser(result.user);
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
} else if (refreshTimes >= maxRefreshTimes) {
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("refresh user after pay error", error);
|
||||
if (refreshTimes >= maxRefreshTimes) {
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
const getVirtualPaySignData = (result) => {
|
||||
// 后端可能返回字符串或对象,这里统一转成微信虚拟支付需要的 JSON 字符串。
|
||||
const signDataObj = typeof result?.signData === "string" ? JSON.parse(result.signData) : result?.signData;
|
||||
if (!signDataObj) return "";
|
||||
|
||||
// 微信虚拟支付短剧/虚拟商品模式需要购买数量;后端未返回时使用订单数量兜底。
|
||||
if (!signDataObj.buyQuantity) {
|
||||
signDataObj.buyQuantity = result.quantity || 1;
|
||||
}
|
||||
return JSON.stringify(signDataObj);
|
||||
};
|
||||
|
||||
const isVirtualPayCancel = (res = {}) => {
|
||||
const message = String(res.errMsg || res.message || "").toLowerCase();
|
||||
const errCode = Number(res.errCode);
|
||||
const errno = Number(res.errno);
|
||||
|
||||
return message.includes("cancel") || errCode === -23 || errno === -2;
|
||||
};
|
||||
|
||||
const onPay = async () => {
|
||||
@@ -317,12 +294,22 @@ const onPay = async () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof wx === "undefined" || !wx.requestVirtualPayment) {
|
||||
uni.showToast({
|
||||
title: "当前环境不支持微信虚拟支付",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
paying.value = true;
|
||||
let waitingPayment = false;
|
||||
let payToast = null;
|
||||
try {
|
||||
const result = await createOrderAPI(vipId);
|
||||
const params = result?.pay?.order?.jsApi?.params;
|
||||
if (!params?.timeStamp || !params?.nonceStr || !params?.package || !params?.paySign) {
|
||||
// 微信虚拟支付创建订单前需要登录 code,服务端用它换取本次支付签名参数。
|
||||
const wxResult = await wxLogin();
|
||||
const result = await virtualPayOrderAPI(vipId, wxResult.code);
|
||||
const finalSignData = getVirtualPaySignData(result);
|
||||
if (!finalSignData || !result?.paySig || !result?.signature) {
|
||||
uni.showToast({
|
||||
title: "支付参数生成失败",
|
||||
icon: "none",
|
||||
@@ -330,34 +317,57 @@ const onPay = async () => {
|
||||
refreshing.value = false;
|
||||
return;
|
||||
}
|
||||
waitingPayment = true;
|
||||
wx.requestPayment({
|
||||
timeStamp: params.timeStamp, // 微信支付时间戳
|
||||
nonceStr: params.nonceStr, // 微信支付随机串
|
||||
package: params.package, // 预支付交易会话标识
|
||||
paySign: params.paySign, // 微信支付签名
|
||||
signType: params.signType || "RSA",
|
||||
success() {
|
||||
uni.showToast({
|
||||
wx.requestVirtualPayment({
|
||||
signData: finalSignData,
|
||||
paySig: result.paySig,
|
||||
signature: result.signature,
|
||||
mode: "short_series_goods",
|
||||
async success() {
|
||||
payToast = {
|
||||
title: "支付成功",
|
||||
icon: "none",
|
||||
});
|
||||
icon: "success",
|
||||
};
|
||||
if (result?.outTradeNo) {
|
||||
try {
|
||||
const orderDetail = await getOrderDetailAPI(result.outTradeNo);
|
||||
console.log("virtual pay order detail", orderDetail);
|
||||
} catch (error) {
|
||||
console.log("virtual pay order detail error", error);
|
||||
}
|
||||
}
|
||||
// 客户端支付成功后,会员是否真正生效仍以服务端用户信息刷新结果为准。
|
||||
refreshUserAfterPay();
|
||||
},
|
||||
fail(res) {
|
||||
console.log("pay error", res);
|
||||
if (res.errMsg && res.errMsg.indexOf("cancel") !== -1) return;
|
||||
uni.showToast({
|
||||
title: "支付失败,请稍后重试",
|
||||
console.log("virtual pay error", res);
|
||||
if (isVirtualPayCancel(res)) {
|
||||
payToast = {
|
||||
title: "支付已取消",
|
||||
icon: "none",
|
||||
};
|
||||
return;
|
||||
}
|
||||
payToast = {
|
||||
title: res.message || "支付失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
},
|
||||
complete() {
|
||||
paying.value = false;
|
||||
if (payToast) {
|
||||
setTimeout(() => {
|
||||
uni.showToast(payToast);
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
});
|
||||
waitingPayment = true;
|
||||
} catch (error) {
|
||||
console.log("create vip order error", error);
|
||||
console.log("create virtual pay order error", error);
|
||||
uni.showToast({
|
||||
title: error.message || "下单失败",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
if (!waitingPayment) paying.value = false;
|
||||
}
|
||||
@@ -365,9 +375,6 @@ const onPay = async () => {
|
||||
|
||||
onShow(loadVipConfig);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearRefreshTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -449,7 +456,7 @@ onBeforeUnmount(() => {
|
||||
<view class="package-list">
|
||||
<view
|
||||
v-for="(pack, index) in getPackages(type)"
|
||||
:key="`${type.key}-${pack.name}`"
|
||||
:key="`${type.key}-${pack.id || pack.name || index}`"
|
||||
class="package-card"
|
||||
:class="{ 'package-card--active': selectedPackageIndex === index }"
|
||||
@click="selectPackage(index)"
|
||||
@@ -460,8 +467,8 @@ onBeforeUnmount(() => {
|
||||
<text class="package-price__symbol">¥</text>
|
||||
<text class="package-price__value">{{ pack.price }}</text>
|
||||
</view>
|
||||
<view class="package-origin">
|
||||
<text>¥{{ pack.original }}</text>
|
||||
<view v-if="pack.originalPrice" class="package-origin">
|
||||
<text>¥{{ pack.originalPrice }}</text>
|
||||
<view class="package-origin__line" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -473,13 +480,14 @@ onBeforeUnmount(() => {
|
||||
hover-class="none"
|
||||
class="activate-btn"
|
||||
:class="type.buttonClass"
|
||||
:disabled="loadingConfig || paying || refreshing"
|
||||
:disabled="loadingConfig || paying || refreshing || !selectedPackage"
|
||||
@click="onPay"
|
||||
>
|
||||
<text v-if="loadingConfig">加载套餐中</text>
|
||||
<text v-else-if="paying">创建订单中</text>
|
||||
<text v-else-if="!refreshing">¥ {{ selectedPackage.price }} 一键激活</text>
|
||||
<text v-else>刷新会员状态中</text>
|
||||
<text v-else-if="refreshing">刷新会员状态中</text>
|
||||
<text v-else-if="selectedPackage">¥ {{ selectedPackage.price }} 一键激活</text>
|
||||
<text v-else>套餐暂不可购买</text>
|
||||
</button>
|
||||
|
||||
<view class="agreement">
|
||||
@@ -746,8 +754,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.package-list {
|
||||
display: flex;
|
||||
width: 984rpx;
|
||||
display: inline-flex;
|
||||
min-width: 100%;
|
||||
padding: 6rpx 84rpx 6rpx 6rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -755,6 +763,7 @@ onBeforeUnmount(() => {
|
||||
.package-card {
|
||||
position: relative;
|
||||
width: 264rpx;
|
||||
flex: 0 0 264rpx;
|
||||
height: 224rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #999999;
|
||||
|
||||
@@ -97,12 +97,10 @@ const cancelOrder = async () => {
|
||||
>复制</text
|
||||
>
|
||||
</view>
|
||||
<text>下单时间:{{ data.vipCreateAt }}</text>
|
||||
<text
|
||||
>支付时间:{{
|
||||
<text>创建时间:{{ data.orderCreateAt }}</text>
|
||||
<text v-if="data.orderStatus === 4">支付时间:{{
|
||||
data.orderStatus === 4 ? data.paymentTime : ""
|
||||
}}</text
|
||||
>
|
||||
}}</text>
|
||||
<text>金额:{{ data.total }} 元</text>
|
||||
<text>支付方式:微信</text>
|
||||
</view>
|
||||
|
||||
+38
-19
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
@@ -52,8 +52,10 @@ let wifiConnectTimer = null;
|
||||
let wifiConnectRequestId = 0;
|
||||
let wifiConnectPollCount = 0;
|
||||
const WIFI_CONNECT_POLL_INTERVAL = 2000;
|
||||
const WIFI_CONNECT_MAX_POLL_COUNT = 15;
|
||||
const WIFI_CONNECT_MAX_POLL_COUNT = 30;
|
||||
const WIFI_CONNECT_FAILED_TEXT = "连接失败,请检查WiFi密码或WiFi状态";
|
||||
const OTA_MIN_BATTERY = 50;
|
||||
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
|
||||
// 控制授权拒绝弹窗显示/隐藏
|
||||
const wifiAuthDeniedVisible = ref(false);
|
||||
|
||||
@@ -271,12 +273,20 @@ const cancelWifiConnectPolling = () => {
|
||||
uni.hideLoading();
|
||||
};
|
||||
|
||||
// 判断设备是否已经在线且连接到 WiFi。
|
||||
// 判断设备电量接口返回的 online/netType 字段,确定设备是否已通过 WiFi 在线。
|
||||
// 返回值含义:true → WiFi 在线成功;"net_fail" → 设备走 4g 失败;false → 未就绪,需继续轮询。
|
||||
const isDeviceConnectedByWifi = (deviceStatus) => {
|
||||
return deviceStatus?.online === true && String(deviceStatus?.netType || "").toLowerCase() === "wifi";
|
||||
// online 不为 true → 设备不在线,需继续轮询
|
||||
if (deviceStatus?.online !== true) return false;
|
||||
const netType = String(deviceStatus?.netType || "").toLowerCase();
|
||||
// online:true + netType:4g → 设备已切 4g,WiFi 连接失败
|
||||
if (netType === "4g") return "net_fail";
|
||||
// online:true + netType:wifi → WiFi 连接成功
|
||||
// online:true + netType:"" → 设备在线但 netType 暂未上报,继续轮询等待
|
||||
return netType === "wifi";
|
||||
};
|
||||
|
||||
// 轮询设备电量接口,确认设备已经切换到 WiFi 在线状态。
|
||||
// 轮询设备电量接口,等待设备切到 WiFi 在线;netType:4g 快速失败,超时 30 次后放弃。
|
||||
const waitForDeviceWifiConnected = (requestId) => {
|
||||
return new Promise((resolve) => {
|
||||
const poll = async () => {
|
||||
@@ -292,10 +302,19 @@ const waitForDeviceWifiConnected = (requestId) => {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (isDeviceConnectedByWifi(deviceStatus)) {
|
||||
const connResult = isDeviceConnectedByWifi(deviceStatus);
|
||||
// online:true + netType:wifi → 成功
|
||||
if (connResult === true) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
// online:true + netType:4g → 立即失败(设备已切 4g,WiFi 连不上)
|
||||
if (connResult === "net_fail") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
// online:false + netType:"" → 继续轮询
|
||||
// online:true + netType:"" → 忽略,继续轮询(netType 暂未上报)
|
||||
} catch (err) {
|
||||
if (requestId !== wifiConnectRequestId) {
|
||||
resolve(false);
|
||||
@@ -436,8 +455,8 @@ const pollUpdateTaskStatus = (taskId) => {
|
||||
|
||||
// 判断设备是否满足 OTA 更新条件,不满足时返回精确提示文案。
|
||||
const getUpdateDisabledReason = (deviceStatus) => {
|
||||
if (deviceStatus?.online !== true) return "设备已离线,请先开启设备并保持在线";
|
||||
if (Number(deviceStatus?.battery) <= 20) return "设备电量不足,请充电至 20% 以上后再更新";
|
||||
if (deviceStatus?.online !== true) return "请先开启智能弓";
|
||||
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) return OTA_LOW_BATTERY_TEXT;
|
||||
if (String(deviceStatus?.netType || "").toLowerCase() !== "wifi") return "设备当前未连接 WiFi,请先连接 WiFi 后再更新";
|
||||
return "";
|
||||
};
|
||||
@@ -529,18 +548,18 @@ const handleWsFail = () => {
|
||||
failUpdate();
|
||||
};
|
||||
|
||||
// 处理更新完成返回,兼容首页 OTA 弹窗入口和设备页普通入口。
|
||||
const handleDone = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success() {
|
||||
const pages = getCurrentPages();
|
||||
const prevPage = pages[pages.length - 2];
|
||||
if (prevPage) {
|
||||
prevPage.$vm.otaState = "update_success";
|
||||
prevPage.$vm.otaVisible = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
const pages = getCurrentPages();
|
||||
const prevPage = pages[pages.length - 2];
|
||||
const prevVm = prevPage?.$vm;
|
||||
|
||||
if (prevVm && "otaState" in prevVm && "otaVisible" in prevVm) {
|
||||
prevVm.otaState = "update_success";
|
||||
prevVm.otaVisible = true;
|
||||
}
|
||||
|
||||
uni.navigateBack({ delta: 1 });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import Container from "@/components/Container.vue";
|
||||
<view class="section">
|
||||
<view class="title">一、段位体系概述</view>
|
||||
<view class="text">
|
||||
我们的段位体系分为多个等级,从低到高依次为:倔强青铜、秩序白银、黄金王者、永恒钻石、最强王者、非凡王者、无双王者、绝世王者、至圣王者、荣耀王者和传奇王者。每个大段位下又分为若干小段位,玩家需要通过积累积分来提升段位。
|
||||
我们的段位体系分为多个等级,从低到高依次为:倔强青铜、秩序白银、荣耀黄金、永恒钻石、最强王者、非凡王者、无双王者、绝世王者、至圣王者、荣耀王者和传奇王者。每个大段位下又分为若干小段位,玩家需要通过积累积分来提升段位。
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -79,7 +79,7 @@ import Container from "@/components/Container.vue";
|
||||
<text>每个小段位需要满 3颗星才能晋升到下一个段位,共9颗星。</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text>黄金王者</text>
|
||||
<text>荣耀黄金</text>
|
||||
<view>
|
||||
<text>黄金1*</text>
|
||||
<text>黄金2*</text>
|
||||
|
||||
Reference in New Issue
Block a user