Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09756ae165 | ||
|
|
b89c003313 | ||
|
|
9dda58b19c | ||
|
|
962596e510 | ||
|
|
8a2fbb0780 | ||
|
|
0c0a00f742 | ||
|
|
66fc7fa8eb | ||
|
|
6363dda204 | ||
|
|
9f4fbbe4c9 | ||
|
|
45957d04b1 | ||
|
|
42cfb5c3d8 | ||
|
|
844f72489e | ||
|
|
d2571854bc | ||
|
|
bb224c9f29 | ||
|
|
82c360f1b1 | ||
|
|
63f3cada0f | ||
|
|
d60c32506f |
@@ -26,8 +26,9 @@ try {
|
||||
}
|
||||
|
||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||
const API_ROOT_URL = BASE_URL.replace(/\/api\/shoot$/, "");
|
||||
// 统一处理业务接口请求,包含登录态、业务错误和特定接口空响应兼容。
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0]) {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
@@ -51,7 +52,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
}
|
||||
if (res.data) {
|
||||
const {code, data, message} = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
if (successCodes.includes(code)) resolve(data);
|
||||
else if (message) {
|
||||
const error = {code, data, message};
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
@@ -698,3 +699,76 @@ export const getMyTenRingRank = (seasonId) => {
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myTenRingRank", data);
|
||||
};
|
||||
|
||||
// 获取当前用户的金币统计,可按门店查询。
|
||||
export const getMyGoldAPI = (storeId) => {
|
||||
const data = {};
|
||||
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||
return request("GET", "/index/gold/my", data);
|
||||
};
|
||||
|
||||
// 分页获取当前用户的金币流水,type:1=获得,2=兑换。
|
||||
export const getGoldLogAPI = ({page = 1, pageSize = 20, type, storeId} = {}) => {
|
||||
const data = {page, pageSize};
|
||||
if (type !== undefined && type !== null) data.type = type;
|
||||
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||
return request("GET", "/index/gold/log", data);
|
||||
};
|
||||
|
||||
// 前台礼品接口位于站点根路径,并使用 code=200 表示成功。
|
||||
export const getGiftListAPI = ({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sort = "coin_desc",
|
||||
storeId,
|
||||
} = {}) => {
|
||||
const data = {page, page_size: pageSize, sort};
|
||||
if (storeId !== undefined && storeId !== null && storeId !== "") {
|
||||
data.store_id = storeId;
|
||||
}
|
||||
return request(
|
||||
"GET",
|
||||
"/gin/api/v1/gift/list",
|
||||
data,
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
export const getGiftDetailAPI = (id) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/gin/api/v1/gift/${id}`,
|
||||
{},
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
// 根据用户定位分页获取附近门店。
|
||||
export const getNearbyStoresAPI = ({
|
||||
longitude,
|
||||
latitude,
|
||||
radius = 65535,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
} = {}) => {
|
||||
return request("GET", "/store/nearby", {
|
||||
longitude,
|
||||
latitude,
|
||||
radius,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
// 分页获取指定门店的公开金币规则。
|
||||
export const getStoreGoldRuleListAPI = ({storeId, page = 1, pageSize = 20} = {}) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/gin/api/v1/super-admin/gold-rule/store/${storeId}/list`,
|
||||
{page, page_size: pageSize},
|
||||
API_ROOT_URL,
|
||||
[0, 200]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
||||
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
||||
|
||||
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
||||
base: "请于计时前完成指定环数对应箭量",
|
||||
endurance: "请于计时前完成累计环数和箭量",
|
||||
precision: "请射箭命中高亮区域",
|
||||
});
|
||||
|
||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
|
||||
|
||||
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
||||
if (!shootData || typeof shootData !== "object") return [];
|
||||
const ring = Number(shootData.ring);
|
||||
const directionAudioKey =
|
||||
shootData.angle !== null &&
|
||||
shootData.angle !== undefined &&
|
||||
directionText
|
||||
? `向${directionText}调整`
|
||||
: "";
|
||||
if (!Number.isFinite(ring) || ring <= 0) {
|
||||
return ["未上靶", directionAudioKey].filter(Boolean);
|
||||
}
|
||||
|
||||
// protobuf 的 false 可能不编码;只要不是明确命中,本箭就反馈未命中。
|
||||
const hitAudioKey = shootData.ok === true ? "Bingo命中目标" : "未命中";
|
||||
return [directionAudioKey, hitAudioKey].filter(Boolean);
|
||||
};
|
||||
|
||||
export const audioFils = {
|
||||
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
|
||||
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
|
||||
@@ -20,6 +47,22 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrda0amn5kqr4j.mp3",
|
||||
距离不足:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6hr2faw28t0ianh0.mp3",
|
||||
"靶纸不符!请更换靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E9%9D%B6%E7%BA%B8%E4%B8%8D%E7%AC%A6%EF%BC%81%E8%AF%B7%E6%9B%B4%E6%8D%A2%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"站距与靶纸合格":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%AB%99%E8%B7%9D%E4%B8%8E%E9%9D%B6%E7%BA%B8%E5%90%88%E6%A0%BC.MP3",
|
||||
"请射箭!测试站距与靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AF%B7%E5%B0%84%E7%AE%AD%EF%BC%81%E6%B5%8B%E8%AF%95%E7%AB%99%E8%B7%9D%E4%B8%8E%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"站距过近,靶纸正确":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E8%BF%87%E8%BF%91%EF%BC%8C%E9%9D%B6%E7%BA%B8%E6%AD%A3%E7%A1%AE.MP3",
|
||||
"站距过近,靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E8%BF%87%E8%BF%91%EF%BC%8C%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"站距合格,靶纸正确":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E5%90%88%E6%A0%BC%EF%BC%8C%E9%9D%B6%E7%BA%B8%E6%AD%A3%E7%A1%AE.MP3",
|
||||
"站距合格,靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E7%AB%99%E8%B7%9D%E5%90%88%E6%A0%BC%EF%BC%8C%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"未识别到靶纸,请瞄准靶纸射箭":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E8%AF%86%E5%88%AB%E5%88%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3",
|
||||
"未发现靶纸,请瞄准靶纸射箭":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E5%8F%91%E7%8E%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3",
|
||||
轮到你了:
|
||||
@@ -54,6 +97,8 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%E6%A3%80%E6%9F%A5%E8%B7%9D%E7%A6%BB%E5%92%8C%E9%9D%B6%E7%BA%B8.mp3",
|
||||
"射箭无效,距离不足":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E8%B7%9D%E7%A6%BB%E4%B8%8D%E8%B6%B3.MP3",
|
||||
"射箭无效,距离不足且靶纸错误":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E8%B7%9D%E7%A6%BB%E4%B8%8D%E8%B6%B3%E4%B8%94%E9%9D%B6%E7%BA%B8%E9%94%99%E8%AF%AF.MP3",
|
||||
"射箭无效,未识别到靶纸":
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%EF%BC%8C%E6%9C%AA%E8%AF%86%E5%88%AB%E5%88%B0%E9%9D%B6%E7%BA%B8.MP3",
|
||||
"射箭无效,靶纸错误":
|
||||
@@ -101,6 +146,16 @@ export const audioFils = {
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3",
|
||||
练习开始:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3",
|
||||
请于计时前完成指定环数对应箭量:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E4%BA%8E%E8%AE%A1%E6%97%B6%E5%89%8D%E5%AE%8C%E6%88%90%E6%8C%87%E5%AE%9A%E7%8E%AF%E6%95%B0%E5%AF%B9%E5%BA%94%E7%AE%AD%E9%87%8F.MP3",
|
||||
请于计时前完成累计环数和箭量:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E4%BA%8E%E8%AE%A1%E6%97%B6%E5%89%8D%E5%AE%8C%E6%88%90%E7%B4%AF%E8%AE%A1%E7%8E%AF%E6%95%B0%E5%92%8C%E7%AE%AD%E9%87%8F.MP3",
|
||||
请射箭命中高亮区域:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD%E5%91%BD%E4%B8%AD%E9%AB%98%E4%BA%AE%E5%8C%BA%E5%9F%9F.MP3",
|
||||
Bingo命中目标:
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Bingo%E5%91%BD%E4%B8%AD%E7%9B%AE%E6%A0%87.MP3",
|
||||
未命中:
|
||||
"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",
|
||||
射箭声音:
|
||||
@@ -126,16 +181,21 @@ const AUDIO_WARM_PRIORITY_KEYS = [
|
||||
"比赛结束",
|
||||
"射击无效",
|
||||
"射箭无效,距离不足",
|
||||
"射箭无效,距离不足且靶纸错误",
|
||||
"射箭无效,未识别到靶纸",
|
||||
"射箭无效,靶纸错误",
|
||||
"请射箭!测试站距与靶纸",
|
||||
"站距过近,靶纸正确",
|
||||
"站距过近,靶纸错误",
|
||||
"站距合格,靶纸正确",
|
||||
"站距合格,靶纸错误",
|
||||
"未识别到靶纸,请瞄准靶纸射箭",
|
||||
"中场休息",
|
||||
"下半场开始",
|
||||
"决金箭轮",
|
||||
"请蓝方射箭",
|
||||
"请红方射箭",
|
||||
"距离合格",
|
||||
"距离不足",
|
||||
"未发现靶纸,请瞄准靶纸射箭",
|
||||
"未上靶",
|
||||
"X环",
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||
import { capsuleHeight, debounce } from "@/util";
|
||||
import { returnToBattle } from "@/utils/matchReturn";
|
||||
const emit = defineEmits(["scrolltolower"]);
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -131,8 +132,9 @@ const goCalibration = async () => {
|
||||
<template>
|
||||
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||
<slot v-if="$slots.header" name="header"></slot>
|
||||
<Header
|
||||
v-if="!isHome"
|
||||
v-else-if="!isHome"
|
||||
:class="headerClass"
|
||||
:title="title"
|
||||
:onBack="onBack"
|
||||
@@ -158,8 +160,10 @@ const goCalibration = async () => {
|
||||
:enhanced="true"
|
||||
:bounces="false"
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="emit('scrolltolower')"
|
||||
:style="{
|
||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
||||
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||
})`,
|
||||
}"
|
||||
|
||||
@@ -125,6 +125,8 @@ onBeforeUnmount(() => {
|
||||
<view
|
||||
:style="[{ color: whiteBackArrow ? '#fff' : '#000' }, titleStyle]"
|
||||
>
|
||||
<slot v-if="$slots.title" name="title"></slot>
|
||||
<template v-else>
|
||||
<view
|
||||
v-if="currentPage === 'pages/point-book'"
|
||||
class="user-header"
|
||||
@@ -190,6 +192,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
</view>
|
||||
</block>
|
||||
</template>
|
||||
</view>
|
||||
<view v-if="pointBook" class="point-book-info">
|
||||
<text>{{ pointBook.bowType.name }}</text>
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -88,7 +92,7 @@ async function onReceiveMessage(message) {
|
||||
currentRoundEnded.value = true;
|
||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(shootData),
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(shootData));
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -171,7 +175,7 @@ async function onReceiveMessage(msg) {
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(msg.shootData),
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
||||
|
||||
@@ -6,10 +6,16 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDistanceCheckAudioKey,
|
||||
getDistanceCheckText,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
const emit = defineEmits(["passed"]);
|
||||
const props = defineProps({
|
||||
guide: {
|
||||
type: Boolean,
|
||||
@@ -23,18 +29,46 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
autoStart: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
const statusText = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(props.count);
|
||||
const timer = ref(null);
|
||||
const autoStartPending = ref(false);
|
||||
const autoStartTriggered = ref(false);
|
||||
let autoStartTimer = null;
|
||||
const DISTANCE_PASSED_AUDIO_KEY = "站距合格,靶纸正确";
|
||||
const AUTO_START_TIMEOUT_MS = 6000;
|
||||
|
||||
const clearAutoStartTimer = () => {
|
||||
if (!autoStartTimer) return;
|
||||
clearTimeout(autoStartTimer);
|
||||
autoStartTimer = null;
|
||||
};
|
||||
|
||||
const triggerAutoStart = () => {
|
||||
if (!autoStartPending.value || autoStartTriggered.value) return;
|
||||
clearAutoStartTimer();
|
||||
autoStartPending.value = false;
|
||||
autoStartTriggered.value = true;
|
||||
emit("passed");
|
||||
};
|
||||
|
||||
const onAudioEnded = (key) => {
|
||||
if (key === DISTANCE_PASSED_AUDIO_KEY) triggerAutoStart();
|
||||
};
|
||||
|
||||
const updateTimer = (value) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭测试距离");
|
||||
audioManager.play("请射箭!测试站距与靶纸");
|
||||
if (props.isBattle) {
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
@@ -42,23 +76,31 @@ onMounted(() => {
|
||||
}, 1000);
|
||||
}
|
||||
uni.$on("update-timer", updateTimer);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
clearAutoStartTimer();
|
||||
uni.$off("update-timer", updateTimer);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
});
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
const rawDistance = Number(msg.shootData?.distance);
|
||||
if (autoStartPending.value || autoStartTriggered.value) return;
|
||||
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||
distance.value = Number.isFinite(rawDistance)
|
||||
? Number((rawDistance / 100).toFixed(2))
|
||||
: 0;
|
||||
if (rawDistance === 0) {
|
||||
audioManager.play("未发现靶纸,请瞄准靶纸射箭");
|
||||
} else if (distance.value >= 5) audioManager.play("距离合格");
|
||||
else audioManager.play("距离不足");
|
||||
statusText.value = getDistanceCheckText(msg.shootData);
|
||||
const audioKey = getDistanceCheckAudioKey(msg.shootData);
|
||||
const validation = getShootValidation(msg.shootData);
|
||||
if (props.autoStart && validation.distanceOk && validation.targetOk) {
|
||||
autoStartPending.value = true;
|
||||
autoStartTimer = setTimeout(triggerAutoStart, AUTO_START_TIMEOUT_MS);
|
||||
}
|
||||
audioManager.play(audioKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +143,12 @@ onBeforeUnmount(() => {
|
||||
模拟射箭
|
||||
</button>
|
||||
<view class="warnning-text">
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离{{ distance }}米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
<text v-else>请调整站位</text>
|
||||
<block v-if="statusText">
|
||||
<text v-if="distance > 0">当前距离{{ distance }}米</text>
|
||||
<text>{{ statusText }}</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>请射箭,测试站距</text>
|
||||
<text>请射箭,测试站距与靶纸</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="user-row">
|
||||
|
||||
@@ -9,7 +9,15 @@ import {
|
||||
getServerMessageTypeName,
|
||||
} from "@/utils/matchProtocol";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getDistanceCheckAudioKey,
|
||||
getInvalidShotAudioKey,
|
||||
} from "@/util";
|
||||
import {
|
||||
getPrecisionShotAudioKeys,
|
||||
getTrainingStartAudioKey,
|
||||
} from "@/audioManager";
|
||||
import {
|
||||
normalizeId,
|
||||
normalizeMatchInfo,
|
||||
@@ -49,20 +57,6 @@ const ENABLE_REALTIME_MESSAGE_LOG = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
function getServerMessageLogSummary(message = {}) {
|
||||
const practiceInfo = message.practice_info || message.practiceInfo || {};
|
||||
const matchInfo = message.match_info || message.matchInfo || {};
|
||||
return {
|
||||
type: message.type,
|
||||
matchId: normalizeId(pickField(message, "matchId", "match_id")),
|
||||
sequence: message.sequence,
|
||||
practiceDetailCount: Array.isArray(practiceInfo.details)
|
||||
? practiceInfo.details.length
|
||||
: 0,
|
||||
roundCount: Array.isArray(matchInfo.rounds) ? matchInfo.rounds.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。
|
||||
const BUSINESS_TYPE_BY_SERVER_TYPE = {
|
||||
[ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart,
|
||||
@@ -87,6 +81,9 @@ function normalizeShootData(shootData) {
|
||||
if (normalized.distance === undefined && normalized.dst !== undefined) {
|
||||
normalized.distance = normalized.dst;
|
||||
}
|
||||
if (normalized.targetOk === undefined && normalized.target_ok !== undefined) {
|
||||
normalized.targetOk = normalized.target_ok;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -152,6 +149,15 @@ function buildBusinessMessage(message) {
|
||||
matchInfo.status = 0;
|
||||
}
|
||||
const practiceInfo = normalizePracticeInfo(message.practice_info);
|
||||
const trainingType = String(
|
||||
practiceInfo.trainingType ||
|
||||
matchInfo.trainingType ||
|
||||
currentContext?.trainingType ||
|
||||
""
|
||||
).trim();
|
||||
if (currentContext && trainingType) {
|
||||
currentContext.trainingType = trainingType;
|
||||
}
|
||||
const businessType = getBusinessType(message, matchInfo);
|
||||
if (!businessType) return null;
|
||||
|
||||
@@ -204,11 +210,15 @@ function buildBusinessMessage(message) {
|
||||
matchWsType: message.type,
|
||||
matchWsTypeName: getServerMessageTypeName(message.type),
|
||||
isSecondHalfStart,
|
||||
...(trainingType ? { trainingType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPracticeSyncMessage(message) {
|
||||
const practiceInfo = normalizePracticeInfo(message.practice_info);
|
||||
if (currentContext && practiceInfo.trainingType) {
|
||||
currentContext.trainingType = String(practiceInfo.trainingType).trim();
|
||||
}
|
||||
const matchId = normalizeId(
|
||||
pickField(message, "matchId", "match_id") ||
|
||||
practiceInfo.id ||
|
||||
@@ -368,16 +378,23 @@ function getShootResultAudioKeys(shootData) {
|
||||
}
|
||||
|
||||
function getTestDistanceAudioKeys(shootData) {
|
||||
const distance = Number(shootData?.distance ?? shootData?.dst);
|
||||
if (Number.isNaN(distance)) return [];
|
||||
if (distance === 0) return ["未发现靶纸,请瞄准靶纸射箭"];
|
||||
return [distance / 100 >= 5 ? "\u8ddd\u79bb\u5408\u683c" : "\u8ddd\u79bb\u4e0d\u8db3"];
|
||||
if (!shootData) return [];
|
||||
return [getDistanceCheckAudioKey(shootData)];
|
||||
}
|
||||
|
||||
function getAckAudioKeys(message, businessMessage) {
|
||||
switch (message.type) {
|
||||
case ServerMessageType.SERVER_MSG_MATCH_START:
|
||||
return [businessMessage?.isSecondHalfStart ? "下半场开始" : "比赛开始"];
|
||||
if (businessMessage?.isSecondHalfStart) return ["下半场开始"];
|
||||
// 个人训练在开始接口成功后主动播报,不再等待 MATCH_START 触发语音。
|
||||
if (
|
||||
getTrainingStartAudioKey(
|
||||
businessMessage?.trainingType || currentContext?.trainingType
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return ["比赛开始"];
|
||||
case ServerMessageType.SERVER_MSG_NOW_YOU:
|
||||
if (isMeleeMessage(message, businessMessage)) return [];
|
||||
return getNowYouAudioKeys(businessMessage);
|
||||
@@ -388,6 +405,17 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
(businessMessage?.trainingType || currentContext?.trainingType) ===
|
||||
"precision"
|
||||
) {
|
||||
const shootData = businessMessage?.shootData;
|
||||
const directionText =
|
||||
shootData?.angle !== null && shootData?.angle !== undefined
|
||||
? getDirectionText(shootData.angle)
|
||||
: "";
|
||||
return getPrecisionShotAudioKeys(shootData, directionText);
|
||||
}
|
||||
return getShootResultAudioKeys(businessMessage?.shootData);
|
||||
case ServerMessageType.SERVER_MSG_MATCH_END:
|
||||
return ["比赛结束"];
|
||||
@@ -805,13 +833,8 @@ function handleMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeName = getServerMessageTypeName(message.type);
|
||||
if (ENABLE_REALTIME_MESSAGE_LOG) {
|
||||
console.log(
|
||||
"收到比赛服 WebSocket 消息",
|
||||
typeName,
|
||||
getServerMessageLogSummary(message)
|
||||
);
|
||||
console.log("收到比赛服 WebSocket 消息", message);
|
||||
}
|
||||
|
||||
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
|
||||
@@ -879,6 +902,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
requestPracticeInfoOnOpen = false,
|
||||
appHideResumable = false,
|
||||
practiceEndAudioKey = "",
|
||||
trainingType = "",
|
||||
force = false,
|
||||
reconnecting = false,
|
||||
reconnectReason = "",
|
||||
@@ -886,6 +910,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
const normalizedMatchId = normalizeId(matchId);
|
||||
const normalizedUserId = normalizeId(userId);
|
||||
const normalizedMode = Number(mode);
|
||||
const normalizedTrainingType = String(trainingType || "").trim();
|
||||
const incomingUrl = normalizeServerUrl(serverAddr, token);
|
||||
const currentUrlToken = getUrlToken(currentContext?.url);
|
||||
const shouldKeepAuthenticatedUrl = !!(
|
||||
@@ -952,6 +977,9 @@ export function connectMatchWebSocket(options = {}) {
|
||||
requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
|
||||
appHideResumable: appHideResumable === true,
|
||||
practiceEndAudioKey: String(practiceEndAudioKey || "").trim(),
|
||||
trainingType:
|
||||
normalizedTrainingType ||
|
||||
(isSameContext ? currentContext?.trainingType || "" : ""),
|
||||
meleeHalfRest: isSameContext
|
||||
? currentContext?.meleeHalfRest === true
|
||||
: false,
|
||||
|
||||
@@ -133,6 +133,29 @@
|
||||
}
|
||||
},
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages/coin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "rules"
|
||||
},
|
||||
{
|
||||
"path": "earning-records"
|
||||
},
|
||||
{
|
||||
"path": "exchange-records"
|
||||
},
|
||||
{
|
||||
"path": "nearby-stores"
|
||||
},
|
||||
{
|
||||
"path": "product-detail"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/device",
|
||||
"pages": [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
cumulative: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
available: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="balance-panel">
|
||||
<view class="balance-list">
|
||||
<view class="balance-item">
|
||||
<text class="balance-item__label">累计金币:</text>
|
||||
<text class="balance-item__value">{{ cumulative }}</text>
|
||||
</view>
|
||||
<view class="balance-list__line" />
|
||||
<view class="balance-item">
|
||||
<text class="balance-item__label">可兑换金币:</text>
|
||||
<text class="balance-item__value">{{ available }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.balance-panel {
|
||||
width: 100%;
|
||||
padding: 8rpx 28rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.balance-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 706rpx;
|
||||
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);
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.balance-item__label {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.balance-item__value {
|
||||
color: #ffd947;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.balance-list__line {
|
||||
width: 2rpx;
|
||||
height: 28rpx;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0 22rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "暂无金币获取记录。",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="empty-state">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/empty-coin-record.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
padding-top: 280rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.empty-state > image {
|
||||
width: 162rpx;
|
||||
height: 190rpx;
|
||||
margin-bottom: 26rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import Header from "@/components/Header.vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
onBack: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="coin-header">
|
||||
<Header title="" :onBack="onBack">
|
||||
<template #title>
|
||||
<view class="coin-header__title-group">
|
||||
<text
|
||||
:class="[
|
||||
'coin-header__title',
|
||||
subtitle ? 'coin-header__title--with-subtitle' : '',
|
||||
]"
|
||||
>
|
||||
{{ title }}
|
||||
</text>
|
||||
<text v-if="subtitle" class="coin-header__subtitle">
|
||||
{{ subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
</Header>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coin-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coin-header__title-group {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 430rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coin-header__title {
|
||||
color: #e7ba80;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coin-header__title--with-subtitle {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.coin-header__subtitle {
|
||||
color: #ffffff;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
menus: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="quick-menu">
|
||||
<view
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
class="quick-menu__item"
|
||||
@click="emit('select', item)"
|
||||
>
|
||||
<image class="quick-menu__icon" :src="item.icon" mode="aspectFit" />
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 26rpx 46rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quick-menu__item {
|
||||
width: 142rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fae6bc;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.quick-menu__icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
records: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "earning",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="record-table"
|
||||
:class="{ 'record-table--exchange': mode === 'exchange' }"
|
||||
>
|
||||
<view class="record-table__header">
|
||||
<template v-if="mode === 'exchange'">
|
||||
<text>兑换内容</text>
|
||||
<text>兑换类型</text>
|
||||
<text>时间</text>
|
||||
<text>金币使用</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text>时间</text>
|
||||
<text>类型</text>
|
||||
<text>金币获取</text>
|
||||
</template>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in records"
|
||||
:key="item.id"
|
||||
class="record-table__row"
|
||||
>
|
||||
<template v-if="mode === 'exchange'">
|
||||
<text class="record-table__exchange-content">{{ item.content }}</text>
|
||||
<text class="record-table__exchange-type">{{ item.type }}</text>
|
||||
<view class="record-table__time record-table__exchange-time">
|
||||
<text>{{ item.date }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
<text class="record-table__amount record-table__exchange-amount">
|
||||
{{ item.amount }}
|
||||
</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="record-table__time">
|
||||
<text>{{ item.date }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
<text class="record-table__type">{{ item.type }}</text>
|
||||
<text class="record-table__amount">{{ item.amount }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-table {
|
||||
width: 670rpx;
|
||||
margin: 20rpx auto 0;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__header,
|
||||
.record-table__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-table__header {
|
||||
height: 66rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(1),
|
||||
.record-table__time {
|
||||
width: 182rpx;
|
||||
flex: 0 0 182rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(2),
|
||||
.record-table__type {
|
||||
width: 304rpx;
|
||||
flex: 0 0 304rpx;
|
||||
}
|
||||
|
||||
.record-table__header > text:nth-child(3),
|
||||
.record-table__amount {
|
||||
width: 180rpx;
|
||||
flex: 0 0 180rpx;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.record-table__row {
|
||||
height: 86rpx;
|
||||
border-top: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__row > view,
|
||||
.record-table__row > text {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.record-table__time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.record-table__time > text:last-child {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.record-table__type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.record-table__row > .record-table__amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.record-table__exchange-content,
|
||||
.record-table__exchange-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(1),
|
||||
.record-table__exchange-content {
|
||||
width: 286rpx;
|
||||
flex: 0 0 286rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(2),
|
||||
.record-table__exchange-type {
|
||||
width: 110rpx;
|
||||
flex: 0 0 110rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(3),
|
||||
.record-table__exchange-time {
|
||||
width: 160rpx;
|
||||
flex: 0 0 160rpx;
|
||||
}
|
||||
|
||||
.record-table--exchange .record-table__header > text:nth-child(4),
|
||||
.record-table__exchange-amount {
|
||||
width: 110rpx;
|
||||
flex: 0 0 110rpx;
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(["authorize"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="location-state">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/location-permission.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>授权获取你的定位,以便推荐附近门店。</text>
|
||||
<button hover-class="none" @click="emit('authorize')">立即授权</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.location-state {
|
||||
width: 100%;
|
||||
padding-top: 190rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location-state > image {
|
||||
width: 168rpx;
|
||||
height: 190rpx;
|
||||
margin-bottom: 52rpx;
|
||||
}
|
||||
|
||||
.location-state > button {
|
||||
width: 360rpx;
|
||||
height: 72rpx;
|
||||
margin-top: 44rpx;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 36rpx;
|
||||
background-color: #ffd947;
|
||||
color: #22222e;
|
||||
font-size: 26rpx;
|
||||
line-height: 72rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.location-state > button::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "gold",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="exchange-notice" :class="`exchange-notice--${variant}`">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/icon-notice.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>暂不支持线上兑换,请前往线下门店进行兑换。</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exchange-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 504rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 auto;
|
||||
padding: 0 16rpx;
|
||||
box-sizing: border-box;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.exchange-notice--gold {
|
||||
border: 2rpx solid rgba(255, 217, 71, 0.28);
|
||||
background-color: rgba(255, 217, 71, 0.05);
|
||||
}
|
||||
|
||||
.exchange-notice--red {
|
||||
width: 492rpx;
|
||||
height: 44rpx;
|
||||
margin: 0 0 0 40rpx;
|
||||
border: none;
|
||||
background-color: rgba(255, 96, 96, 0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.exchange-notice > image {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const DEFAULT_PRODUCT_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-item.png";
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const current = ref(0);
|
||||
const slideImages = computed(() => {
|
||||
const images = props.images.filter(
|
||||
(image) => typeof image === "string" && image.trim()
|
||||
);
|
||||
return images.length ? images : [DEFAULT_PRODUCT_IMAGE];
|
||||
});
|
||||
|
||||
const onChange = (event) => {
|
||||
current.value = event.detail.current;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="hero-swiper">
|
||||
<swiper class="hero-swiper__body" :duration="260" @change="onChange">
|
||||
<swiper-item v-for="(image, index) in slideImages" :key="index">
|
||||
<view class="hero-swiper__item">
|
||||
<image
|
||||
class="hero-swiper__background"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image class="hero-swiper__product" :src="image" mode="aspectFit" />
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="hero-swiper__dots">
|
||||
<view
|
||||
v-for="(_, index) in slideImages"
|
||||
:key="index"
|
||||
class="hero-swiper__dot"
|
||||
:class="{ 'hero-swiper__dot--active': current === index }"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-swiper,
|
||||
.hero-swiper__body,
|
||||
.hero-swiper__item {
|
||||
position: relative;
|
||||
width: 750rpx;
|
||||
height: 750rpx;
|
||||
}
|
||||
|
||||
.hero-swiper__background,
|
||||
.hero-swiper__product {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-swiper__dots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20rpx;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-swiper__dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin: 0 6rpx;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(34, 34, 46, 0.55);
|
||||
}
|
||||
|
||||
.hero-swiper__dot--active {
|
||||
background-color: #ffd947;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_PRODUCT_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const productImage = computed(() => {
|
||||
const image = props.product?.image;
|
||||
return typeof image === "string" && image.trim()
|
||||
? image
|
||||
: DEFAULT_PRODUCT_IMAGE;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="product-card">
|
||||
<view class="product-card__image-wrap">
|
||||
<image class="product-card__image" :src="productImage" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="product-card__name">{{ product.name }}</text>
|
||||
<view class="product-card__footer">
|
||||
<text>{{ product.cost }}金币</text>
|
||||
<text class="product-card__divider">|</text>
|
||||
<text>剩余{{ product.stock }}个</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
width: 336rpx;
|
||||
height: 418rpx;
|
||||
padding: 10rpx 10rpx 14rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(255, 217, 71, 0.1);
|
||||
border-radius: 12rpx;
|
||||
background-color: rgba(84, 67, 29, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__image-wrap {
|
||||
position: relative;
|
||||
width: 312rpx;
|
||||
height: 312rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-card__name {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: #fff0c9;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.product-card__footer > text:first-child {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.product-card__footer > text:last-child {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.product-card__divider {
|
||||
margin: 0 10rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const selectStore = () => {
|
||||
emit("select", props.store);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="store-card" hover-class="none" @click="selectStore">
|
||||
<image class="store-card__photo" :src="store.image" mode="aspectFill" />
|
||||
<text class="store-card__name">{{ store.name }}</text>
|
||||
<view class="store-card__info">
|
||||
<view class="store-card__row">
|
||||
<text>地址:{{ store.address }}</text>
|
||||
</view>
|
||||
<view class="store-card__row">
|
||||
<text>电话:{{ store.phone }}</text>
|
||||
</view>
|
||||
<view class="store-card__row">
|
||||
<text>营业时间:{{ store.hours }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-card {
|
||||
width: 650rpx;
|
||||
margin: 22rpx auto 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-card__photo {
|
||||
width: 650rpx;
|
||||
height: 324rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.store-card__name {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
margin-left: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.store-card__info {
|
||||
width: 584rpx;
|
||||
margin-top: 12rpx;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
.store-card__row {
|
||||
margin-top: 0;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
export const quickMenus = [
|
||||
{
|
||||
key: "rules",
|
||||
label: "金币规则",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-rules.png",
|
||||
},
|
||||
{
|
||||
key: "earningRecords",
|
||||
label: "获取明细",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-earning-records.png",
|
||||
},
|
||||
{
|
||||
key: "exchangeRecords",
|
||||
label: "兑换记录",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-exchange-records.png",
|
||||
},
|
||||
{
|
||||
key: "nearbyStores",
|
||||
label: "附近门店",
|
||||
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getGoldLogAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const earningRecords = ref([]);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !earningRecords.value.length);
|
||||
|
||||
const formatRecord = (item = {}) => {
|
||||
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||
const amount = Number(item.amount) || 0;
|
||||
return {
|
||||
id: item.id,
|
||||
date,
|
||||
time,
|
||||
type: item.remark || item.typeDesc || "-",
|
||||
amount: amount > 0 ? `+${amount}` : String(amount),
|
||||
};
|
||||
};
|
||||
|
||||
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = earningRecords.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRecords = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGoldLogAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
type: 1,
|
||||
storeId: storeId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(formatRecord);
|
||||
earningRecords.value = reset
|
||||
? mappedList
|
||||
: earningRecords.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
earningRecords.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币获取明细失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRecords({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||
<template #header>
|
||||
<CoinHeader title="金币获取明细" />
|
||||
</template>
|
||||
<view class="records-page">
|
||||
<CoinEmptyState v-if="showEmpty" />
|
||||
<CoinRecordList v-else :records="earningRecords" />
|
||||
<view
|
||||
v-if="loading || (noMore && earningRecords.length)"
|
||||
class="records-page__status"
|
||||
>
|
||||
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.records-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.records-page__status {
|
||||
padding: 24rpx 0 32rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getGoldLogAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const exchangeRecords = ref([]);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !exchangeRecords.value.length);
|
||||
|
||||
const formatRecord = (item = {}) => {
|
||||
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||
return {
|
||||
id: item.id,
|
||||
content: item.remark || "-",
|
||||
type: item.typeDesc || "-",
|
||||
date,
|
||||
time,
|
||||
amount: String(Number(item.amount) || 0),
|
||||
};
|
||||
};
|
||||
|
||||
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = exchangeRecords.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRecords = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGoldLogAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
type: 2,
|
||||
storeId: storeId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(formatRecord);
|
||||
exchangeRecords.value = reset
|
||||
? mappedList
|
||||
: exchangeRecords.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
exchangeRecords.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币兑换记录失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRecords({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||
<template #header>
|
||||
<CoinHeader title="金币兑换记录" />
|
||||
</template>
|
||||
<view class="records-page">
|
||||
<CoinEmptyState v-if="showEmpty" text="暂无金币兑换记录。" />
|
||||
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
|
||||
<view
|
||||
v-if="loading || (noMore && exchangeRecords.length)"
|
||||
class="records-page__status"
|
||||
>
|
||||
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.records-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.records-page__status {
|
||||
padding: 24rpx 0 32rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<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 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";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const coinSummary = ref({
|
||||
cumulative: 0,
|
||||
available: 0,
|
||||
});
|
||||
const selectedStoreId = ref("");
|
||||
const selectedStoreName = ref("");
|
||||
const rewardProducts = ref([]);
|
||||
const productPage = ref(0);
|
||||
const productTotal = ref(0);
|
||||
const productLoading = ref(false);
|
||||
const productNoMore = ref(false);
|
||||
|
||||
const loadCoinSummary = async () => {
|
||||
try {
|
||||
const result = await getMyGoldAPI(selectedStoreId.value);
|
||||
coinSummary.value = {
|
||||
cumulative: Number(result?.totalGold) || 0,
|
||||
available: Number(result?.usableGold) || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("加载金币统计失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProducts = async ({ reset = false } = {}) => {
|
||||
if (productLoading.value || (!reset && productNoMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : productPage.value + 1;
|
||||
productLoading.value = true;
|
||||
if (reset) productNoMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getGiftListAPI({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
storeId: selectedStoreId.value,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
cost: Number(item.coin_price) || 0,
|
||||
stock: Number(item.stock) || 0,
|
||||
image: item.cover_image || "",
|
||||
}));
|
||||
|
||||
rewardProducts.value = reset
|
||||
? mappedList
|
||||
: rewardProducts.value.concat(mappedList);
|
||||
productPage.value = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||
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;
|
||||
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
productNoMore.value = rewardProducts.value.length >= totalCount;
|
||||
} else {
|
||||
productNoMore.value = list.length < responsePageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
rewardProducts.value = [];
|
||||
productPage.value = 0;
|
||||
}
|
||||
console.error("加载礼品列表失败", error);
|
||||
} finally {
|
||||
productLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const menuRoutes = {
|
||||
rules: "/pages/coin/rules",
|
||||
earningRecords: "/pages/coin/earning-records",
|
||||
exchangeRecords: "/pages/coin/exchange-records",
|
||||
};
|
||||
|
||||
const buildStorePageUrl = (path, extraQuery = "") => {
|
||||
const query = [
|
||||
`storeId=${encodeURIComponent(selectedStoreId.value)}`,
|
||||
`storeName=${encodeURIComponent(selectedStoreName.value)}`,
|
||||
extraQuery,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("&");
|
||||
return `${path}?${query}`;
|
||||
};
|
||||
|
||||
const decodeRouteText = (value = "") => {
|
||||
const text = String(value);
|
||||
try {
|
||||
return decodeURIComponent(text.replace(/\+/g, " "));
|
||||
} catch (error) {
|
||||
console.error("门店名称解码失败", error);
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuSelect = (menu) => {
|
||||
if (menu.key === "nearbyStores") {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = menuRoutes[menu.key];
|
||||
if (!url) {
|
||||
uni.showToast({
|
||||
title: "功能开发中",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: buildStorePageUrl(url),
|
||||
});
|
||||
};
|
||||
|
||||
const toProductDetail = (product) => {
|
||||
uni.navigateTo({
|
||||
url: buildStorePageUrl(
|
||||
"/pages/coin/product-detail",
|
||||
`id=${encodeURIComponent(product.id)}`
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const storeId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
selectedStoreId.value = storeId;
|
||||
selectedStoreName.value = decodeRouteText(options.storeName);
|
||||
loadCoinSummary();
|
||||
loadProducts({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
|
||||
<template #header>
|
||||
<CoinHeader title="我的金币" :subtitle="selectedStoreName" />
|
||||
</template>
|
||||
<view class="coin-page">
|
||||
<CoinBalancePanel
|
||||
:cumulative="coinSummary.cumulative"
|
||||
:available="coinSummary.available"
|
||||
/>
|
||||
<CoinQuickMenu :menus="quickMenus" @select="onMenuSelect" />
|
||||
<OfflineExchangeNotice />
|
||||
<view class="reward-section">
|
||||
<view class="reward-grid">
|
||||
<view
|
||||
v-for="product in rewardProducts"
|
||||
:key="product.id"
|
||||
class="reward-grid__item"
|
||||
@click="toProductDetail(product)"
|
||||
>
|
||||
<RewardProductCard :product="product" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coin-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-section {
|
||||
padding: 20rpx 28rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reward-grid__item {
|
||||
width: 336rpx;
|
||||
margin-right: 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.reward-grid__item:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<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 LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||
import StoreCard from "./components/StoreCard.vue";
|
||||
import { getNearbyStoresAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_STORE_IMAGE =
|
||||
"https://static.shelingxingqiu.com/shootmini/static/coin/store-photo.png";
|
||||
const showPermissionState = ref(false);
|
||||
const stores = ref([]);
|
||||
const location = ref(null);
|
||||
const page = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
|
||||
const getCurrentLocation = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.getLocation({
|
||||
type: "gcj02",
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const mapStore = (item = {}) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
address: item.address || "",
|
||||
phone: item.phone || "",
|
||||
hours: item.businessHours || "",
|
||||
image: item.coverImage || DEFAULT_STORE_IMAGE,
|
||||
});
|
||||
|
||||
const loadStores = async ({ reset = false } = {}) => {
|
||||
if (!location.value || loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getNearbyStoresAPI({
|
||||
...location.value,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map(mapStore);
|
||||
stores.value = reset ? mappedList : stores.value.concat(mappedList);
|
||||
page.value = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize = Number(result?.pageSize) || PAGE_SIZE;
|
||||
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = page.value >= pageCount;
|
||||
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = stores.value.length >= totalCount;
|
||||
} else {
|
||||
noMore.value = list.length < responsePageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
stores.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载附近门店失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const locateAndLoadStores = async () => {
|
||||
try {
|
||||
const position = await getCurrentLocation();
|
||||
location.value = {
|
||||
longitude: position.longitude,
|
||||
latitude: position.latitude,
|
||||
};
|
||||
showPermissionState.value = false;
|
||||
await loadStores({ reset: true });
|
||||
} catch (error) {
|
||||
showPermissionState.value = true;
|
||||
loaded.value = true;
|
||||
console.error("获取定位失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const authorizeLocation = () => {
|
||||
uni.openSetting({
|
||||
success: locateAndLoadStores,
|
||||
fail: locateAndLoadStores,
|
||||
});
|
||||
};
|
||||
|
||||
// 选择门店后替换当前页面,返回时直接回到进入金币模块前的页面。
|
||||
const selectStore = (store) => {
|
||||
const storeId = String(store?.id || "");
|
||||
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||
uni.showToast({
|
||||
title: "门店信息无效",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storeName = encodeURIComponent(store?.name || "");
|
||||
uni.redirectTo({
|
||||
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}`,
|
||||
});
|
||||
};
|
||||
|
||||
onLoad(locateAndLoadStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadStores">
|
||||
<template #header>
|
||||
<CoinHeader title="附近门店" />
|
||||
</template>
|
||||
<view class="stores-page">
|
||||
<LocationPermissionState
|
||||
v-if="showPermissionState"
|
||||
@authorize="authorizeLocation"
|
||||
/>
|
||||
<template v-else>
|
||||
<StoreCard
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:store="store"
|
||||
@select="selectStore"
|
||||
/>
|
||||
<text v-if="loaded && !stores.length" class="stores-page__more">
|
||||
附近暂无门店~
|
||||
</text>
|
||||
<text v-else-if="noMore" class="stores-page__more">没有更多门店了~</text>
|
||||
</template>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stores-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 54rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stores-page__more {
|
||||
display: block;
|
||||
width: 650rpx;
|
||||
margin: 26rpx auto 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<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 ProductHeroSwiper from "./components/ProductHeroSwiper.vue";
|
||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||
import { getGiftDetailAPI } from "@/apis";
|
||||
|
||||
const productDetail = ref({
|
||||
name: "",
|
||||
coinPrice: 0,
|
||||
stock: 0,
|
||||
images: [],
|
||||
descriptions: [],
|
||||
});
|
||||
|
||||
const loadProductDetail = async (id) => {
|
||||
try {
|
||||
const result = await getGiftDetailAPI(id);
|
||||
const images = Array.isArray(result?.images)
|
||||
? result.images
|
||||
.slice()
|
||||
.sort((first, second) =>
|
||||
(Number(first?.sort_order) || 0) - (Number(second?.sort_order) || 0)
|
||||
)
|
||||
.map((item) => item?.image_url)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
productDetail.value = {
|
||||
...productDetail.value,
|
||||
name: result?.name || "",
|
||||
coinPrice: Number(result?.coin_price) || 0,
|
||||
stock: Number(result?.stock) || 0,
|
||||
images,
|
||||
descriptions: result?.description
|
||||
? String(result.description).split(/\r?\n/).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("加载礼品详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const id = Number(options.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
uni.showToast({
|
||||
title: "商品参数无效",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loadProductDetail(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true">
|
||||
<template #header>
|
||||
<CoinHeader title="商品详情" />
|
||||
</template>
|
||||
<view class="product-detail">
|
||||
<ProductHeroSwiper
|
||||
:images="productDetail.images"
|
||||
/>
|
||||
<view class="product-detail__summary">
|
||||
<text class="product-detail__name">{{ productDetail.name }}</text>
|
||||
<view class="product-detail__balance">
|
||||
<text>金币:</text>
|
||||
<text class="product-detail__amount">{{ productDetail.coinPrice }}</text>
|
||||
<text>(剩余{{ productDetail.stock }}个)</text>
|
||||
</view>
|
||||
</view>
|
||||
<OfflineExchangeNotice variant="red" />
|
||||
<view class="product-detail__content">
|
||||
<text
|
||||
v-for="(description, index) in productDetail.descriptions"
|
||||
:key="index"
|
||||
class="product-detail__paragraph"
|
||||
>
|
||||
{{ description }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-detail {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding-bottom: 60rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: #22222e;
|
||||
}
|
||||
|
||||
.product-detail__summary {
|
||||
padding: 30rpx 40rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-detail__name {
|
||||
color: #ffd947;
|
||||
font-size: 52rpx;
|
||||
line-height: 74rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-detail__balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 10rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.product-detail__amount {
|
||||
margin-right: 6rpx;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
line-height: 50rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-detail__content {
|
||||
padding: 38rpx 40rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 2rpx solid rgba(255, 217, 71, 0.05);
|
||||
}
|
||||
|
||||
.product-detail__paragraph {
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: justify;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import CoinHeader from "./components/CoinHeader.vue";
|
||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||
import { getStoreGoldRuleListAPI } from "@/apis";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const storeId = ref("");
|
||||
const coinRules = ref([]);
|
||||
const page = ref(0);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const loaded = ref(false);
|
||||
const showEmpty = computed(() => loaded.value && !coinRules.value.length);
|
||||
|
||||
const updatePaginationState = (result, list, nextPage) => {
|
||||
const currentPage = Number(result?.page) || nextPage;
|
||||
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||
const responsePageSize =
|
||||
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||
|
||||
page.value = currentPage;
|
||||
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||
noMore.value = currentPage >= pageCount;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||
noMore.value = coinRules.value.length >= totalCount;
|
||||
return;
|
||||
}
|
||||
noMore.value = list.length < responsePageSize;
|
||||
};
|
||||
|
||||
const loadRules = async ({ reset = false } = {}) => {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
|
||||
const nextPage = reset ? 1 : page.value + 1;
|
||||
loading.value = true;
|
||||
if (reset) noMore.value = false;
|
||||
|
||||
try {
|
||||
const result = await getStoreGoldRuleListAPI({
|
||||
storeId: storeId.value,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const list = Array.isArray(result?.list) ? result.list : [];
|
||||
const mappedList = list.map((item) => ({
|
||||
id: item.id,
|
||||
content: item.content || "",
|
||||
}));
|
||||
|
||||
coinRules.value = reset ? mappedList : coinRules.value.concat(mappedList);
|
||||
updatePaginationState(result, list, nextPage);
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
coinRules.value = [];
|
||||
page.value = 0;
|
||||
}
|
||||
console.error("加载金币规则失败", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const currentStoreId = String(options.storeId || "");
|
||||
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = currentStoreId;
|
||||
loadRules({ reset: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="6" :isHome="true" @scrolltolower="loadRules">
|
||||
<template #header>
|
||||
<CoinHeader title="金币规则" />
|
||||
</template>
|
||||
<view class="rules-page">
|
||||
<CoinEmptyState v-if="showEmpty" text="暂无金币规则。" />
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="rule in coinRules"
|
||||
:key="rule.id"
|
||||
class="rules-page__item"
|
||||
>
|
||||
<rich-text class="rules-page__content" :nodes="rule.content" />
|
||||
</view>
|
||||
</template>
|
||||
<view v-if="loading" class="rules-page__status">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rules-page {
|
||||
width: 100%;
|
||||
padding: 28rpx 38rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rules-page__item {
|
||||
margin-bottom: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 28rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rules-page__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rules-page__status {
|
||||
padding: 12rpx 0 20rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
} from "@/apis";
|
||||
import { connectMatchWebSocket, closeMatchWebSocket } from "@/matchWebsocket";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import {
|
||||
wxShare,
|
||||
debounce,
|
||||
getDistanceCheckAudioKey,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -154,14 +159,11 @@ async function onReceiveMessage(msg) {
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(() => onOver(msg), 1500);
|
||||
} else if (msg.type === MESSAGETYPESV2.TestDistance && step.value === 3) {
|
||||
const rawDistance = Number(msg.shootData?.distance);
|
||||
if (rawDistance === 0) {
|
||||
audioManager.play("未发现靶纸,请瞄准靶纸射箭");
|
||||
} else if (rawDistance / 100 >= 5) {
|
||||
audioManager.play("距离合格");
|
||||
btnDisabled.value = false;
|
||||
showGuide.value = true;
|
||||
} else audioManager.play("距离不足");
|
||||
const result = getShootValidation(msg.shootData);
|
||||
const isQualified = result.distanceOk && result.targetOk;
|
||||
audioManager.play(getDistanceCheckAudioKey(msg.shootData));
|
||||
btnDisabled.value = !isQualified;
|
||||
showGuide.value = isQualified;
|
||||
}
|
||||
// messages.forEach((msg) => {
|
||||
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
import { tempBindOrgAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const scene = ref("");
|
||||
const status = ref("idle");
|
||||
const errorMessage = ref("");
|
||||
const showSignin = ref(false);
|
||||
const binding = ref(false);
|
||||
const bindInfo = ref({});
|
||||
|
||||
const successInfo = computed(() => {
|
||||
const result = bindInfo.value || {};
|
||||
|
||||
return {
|
||||
avatar: user.value.avatar,
|
||||
nickName: user.value.nickName || "--",
|
||||
mobile: result.mobile || "--",
|
||||
storeName: result.storeName || "--",
|
||||
};
|
||||
});
|
||||
|
||||
const getToken = () => {
|
||||
try {
|
||||
@@ -40,9 +59,17 @@ const bindOrg = async () => {
|
||||
binding.value = true;
|
||||
status.value = "binding";
|
||||
errorMessage.value = "";
|
||||
bindInfo.value = {};
|
||||
|
||||
try {
|
||||
await tempBindOrgAPI(scene.value);
|
||||
const result = (await tempBindOrgAPI(scene.value)) || {};
|
||||
if (result.success !== true) {
|
||||
status.value = "failed";
|
||||
errorMessage.value = result.msg || "授权登录失败,请稍后重试。";
|
||||
return;
|
||||
}
|
||||
|
||||
bindInfo.value = result;
|
||||
status.value = "success";
|
||||
} catch (error) {
|
||||
if (error?.type === "AUTH_INVALID") {
|
||||
@@ -61,6 +88,10 @@ const handleLoginSuccess = async () => {
|
||||
await bindOrg();
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
uni.reLaunch({ url: "/pages/index" });
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
try {
|
||||
const value = decodeURIComponent(String(options?.scene || ""));
|
||||
@@ -83,45 +114,74 @@ onLoad((options) => {
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<AppBackground :type="6" bgColor="#20202c" />
|
||||
|
||||
<view class="content">
|
||||
<text class="title">机构设备绑定</text>
|
||||
<text class="title">登录门店Pad端</text>
|
||||
|
||||
<view v-if="status === 'idle' || status === 'binding'" class="state">
|
||||
<text class="state-title">正在绑定</text>
|
||||
<text class="description">正在绑定机构设备,请稍候……</text>
|
||||
<view
|
||||
v-if="status === 'idle' || status === 'binding'"
|
||||
class="state message-state"
|
||||
>
|
||||
<text class="state-title">正在授权登录</text>
|
||||
<text class="description">正在登录门店Pad端,请稍候……</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="status === 'login'" class="state">
|
||||
<text class="state-title">请先登录</text>
|
||||
<text class="description">
|
||||
{{
|
||||
errorMessage ||
|
||||
"登录后即可绑定当前机构设备。关闭登录窗口后,也可以再次点击下方按钮继续。"
|
||||
}}
|
||||
</text>
|
||||
<SButton width="560rpx" :rounded="20" :onClick="openSignin">
|
||||
<text>立即登录</text>
|
||||
<view v-else-if="status === 'login'" class="state login-state">
|
||||
<image
|
||||
class="login-icon"
|
||||
src="../static/org-bind/login-icon.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="login-title">{{
|
||||
errorMessage || "请先登录小程序"
|
||||
}}</text>
|
||||
<SButton width="600rpx" :rounded="22" :onClick="openSignin">
|
||||
<text>微信授权登录</text>
|
||||
</SButton>
|
||||
</view>
|
||||
|
||||
<view v-else-if="status === 'success'" class="state">
|
||||
<text class="state-title">绑定成功</text>
|
||||
<text class="description">
|
||||
您的账号已成功绑定机构设备,请返回 iPad 继续操作。
|
||||
</text>
|
||||
<view v-else-if="status === 'success'" class="state success-state">
|
||||
<view class="avatar-wrap">
|
||||
<Avatar
|
||||
:src="successInfo.avatar"
|
||||
:size="176"
|
||||
sizeUnit="rpx"
|
||||
imageMode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
class="success-badge"
|
||||
src="../static/org-bind/green-gou.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="success-title">已成功授权登录射灵星球门店Pad端。</text>
|
||||
<view class="account-info">
|
||||
<text>登录账号:{{ successInfo.nickName }}</text>
|
||||
<text>手机号:{{ successInfo.mobile }}</text>
|
||||
<text>登录门店:{{ successInfo.storeName }}</text>
|
||||
<text class="warm-tip">
|
||||
温馨提示:离开门店时候记得在Pad退出登录哦!
|
||||
</text>
|
||||
</view>
|
||||
<view class="success-action">
|
||||
<SButton width="600rpx" :rounded="22" :onClick="goHome">
|
||||
<text>回到首页</text>
|
||||
</SButton>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="status === 'failed'" class="state">
|
||||
<text class="state-title">绑定失败</text>
|
||||
<view v-else-if="status === 'failed'" class="state message-state">
|
||||
<text class="state-title">授权登录失败</text>
|
||||
<text class="description">{{ errorMessage }}</text>
|
||||
<SButton width="560rpx" :rounded="20" :onClick="bindOrg">
|
||||
<text>重新绑定</text>
|
||||
<SButton width="600rpx" :rounded="22" :onClick="bindOrg">
|
||||
<text>重新登录</text>
|
||||
</SButton>
|
||||
</view>
|
||||
|
||||
<view v-else class="state">
|
||||
<view v-else class="state message-state">
|
||||
<text class="state-title">二维码无效</text>
|
||||
<text class="description">请重新扫描机构设备上的小程序码。</text>
|
||||
<text class="description">请重新扫描门店Pad端上的小程序码。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -139,32 +199,97 @@ onLoad((options) => {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: calc(var(--status-bar-height) + 120rpx) 48rpx 80rpx;
|
||||
background-color: #000;
|
||||
padding: calc(var(--status-bar-height) + 64rpx) 48rpx 80rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #d8ad69;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: 100%;
|
||||
margin-top: 120rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-state,
|
||||
.success-state {
|
||||
margin-top: 320rpx;
|
||||
}
|
||||
|
||||
.message-state {
|
||||
margin-top: 240rpx;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
width: 176rpx;
|
||||
height: 176rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 28rpx 0 60rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.success-badge {
|
||||
position: absolute;
|
||||
right: -2rpx;
|
||||
bottom: 4rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
width: 572rpx;
|
||||
margin-top: 28rpx;
|
||||
color: #fed847;
|
||||
font-size: 34rpx;
|
||||
font-weight: 500;
|
||||
line-height: 48rpx;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
box-sizing: border-box;
|
||||
width: 572rpx;
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #FFFFFF;
|
||||
font-size: 28rpx;
|
||||
line-height: 52rpx;
|
||||
}
|
||||
|
||||
.warm-tip {
|
||||
margin-top: 40rpx;
|
||||
color: #b8b8bd;
|
||||
font-size: 24rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.success-action {
|
||||
margin-top: 64rpx;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
font-size: 36rpx;
|
||||
font-size: 38rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
|
||||
const start = ref(false);
|
||||
const practiceStarting = ref(false);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const total = 12;
|
||||
@@ -156,12 +157,18 @@ const beginPractise = async () => {
|
||||
};
|
||||
|
||||
const onReady = async () => {
|
||||
const result = await beginPractise();
|
||||
if (!result) return;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
if (practiceStarting.value || start.value) return;
|
||||
practiceStarting.value = true;
|
||||
try {
|
||||
const result = await beginPractise();
|
||||
if (!result) return;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
} finally {
|
||||
practiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onOver = async (message) => {
|
||||
@@ -438,7 +445,11 @@ onBeforeUnmount(() => {
|
||||
:onBack="exitPractise"
|
||||
>
|
||||
<view>
|
||||
<TestDistance v-if="!start && !practiseResult.id" />
|
||||
<TestDistance
|
||||
v-if="!start && !practiseResult.id"
|
||||
:autoStart="true"
|
||||
@passed="onReady"
|
||||
/>
|
||||
<block v-else>
|
||||
<ShootProgress
|
||||
:tips="`${
|
||||
|
||||
@@ -37,6 +37,7 @@ const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
|
||||
const start = ref(false);
|
||||
const practiceStarting = ref(false);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const total = 36;
|
||||
@@ -155,12 +156,18 @@ const beginPractise = async () => {
|
||||
};
|
||||
|
||||
const onReady = async () => {
|
||||
const result = await beginPractise();
|
||||
if (!result) return;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
if (practiceStarting.value || start.value) return;
|
||||
practiceStarting.value = true;
|
||||
try {
|
||||
const result = await beginPractise();
|
||||
if (!result) return;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
} finally {
|
||||
practiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onOver = async (message) => {
|
||||
@@ -452,7 +459,11 @@ onBeforeUnmount(() => {
|
||||
:onBack="exitPractise"
|
||||
>
|
||||
<view>
|
||||
<TestDistance v-if="!start && !practiseResult.id" />
|
||||
<TestDistance
|
||||
v-if="!start && !practiseResult.id"
|
||||
:autoStart="true"
|
||||
@passed="onReady"
|
||||
/>
|
||||
<block v-else>
|
||||
<ShootProgress
|
||||
:tips="`请连续射${total}支箭`"
|
||||
|
||||
@@ -6,6 +6,7 @@ import Avatar from "./Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDistanceCheckAudioKey, getDistanceCheckText } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
const statusText = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(props.count);
|
||||
const timer = ref(null);
|
||||
@@ -34,7 +36,7 @@ const updateTimer = (value) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭测试距离");
|
||||
audioManager.play("请射箭!测试站距与靶纸");
|
||||
if (props.isBattle) {
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
@@ -51,14 +53,12 @@ onBeforeUnmount(() => {
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
const rawDistance = Number(msg.shootData?.distance);
|
||||
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||
distance.value = Number.isFinite(rawDistance)
|
||||
? Number((rawDistance / 100).toFixed(2))
|
||||
: 0;
|
||||
if (rawDistance === 0) {
|
||||
audioManager.play("未发现靶纸,请瞄准靶纸射箭");
|
||||
} else if (distance.value >= 5) audioManager.play("距离合格");
|
||||
else audioManager.play("距离不足");
|
||||
statusText.value = getDistanceCheckText(msg.shootData);
|
||||
audioManager.play(getDistanceCheckAudioKey(msg.shootData));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +101,12 @@ onBeforeUnmount(() => {
|
||||
模拟射箭
|
||||
</button>
|
||||
<view class="warnning-text">
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离{{ distance }}米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
<text v-else>请调整站位</text>
|
||||
<block v-if="statusText">
|
||||
<text v-if="distance > 0">当前距离{{ distance }}米</text>
|
||||
<text>{{ statusText }}</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>请射箭,测试站距</text>
|
||||
<text>请射箭,测试站距与靶纸</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="user-row">
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
MATCH_WS_STATE_EVENT,
|
||||
} from "@/matchWebsocket";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
import { takeMatchReturnSnapshot } from "@/utils/matchReturn";
|
||||
import audioManager, {
|
||||
AUDIO_INTERRUPTION_BEGIN_EVENT,
|
||||
@@ -1133,7 +1137,7 @@ async function runBattleEndTask(task, runId) {
|
||||
async function runInvalidShotTask(task, runId) {
|
||||
if (!isQueueAlive(runId)) return;
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(task.message?.shootData),
|
||||
icon: "none",
|
||||
});
|
||||
await playAudioKeys(getInvalidShotAudioKey(task.message?.shootData), {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import audioManager, { getTrainingStartAudioKey } from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText, getInvalidShotAudioKey } from "@/util";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
import useStore from "@/store";
|
||||
@@ -203,7 +207,10 @@ async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
// 已实现的个人训练由页面播放专属提示,避免重复播报。
|
||||
if (!getTrainingStartAudioKey(props.trainingType)) {
|
||||
audioManager.play("比赛开始");
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("练习结束", false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
@@ -238,7 +245,7 @@ async function onReceiveMessage(msg) {
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
title: getInvalidShotText(msg.shootData),
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
||||
|
||||
@@ -6,10 +6,16 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDistanceCheckAudioKey,
|
||||
getDistanceCheckText,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
const emit = defineEmits(["passed"]);
|
||||
const props = defineProps({
|
||||
guide: {
|
||||
type: Boolean,
|
||||
@@ -27,18 +33,46 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: "",
|
||||
},
|
||||
autoStart: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
const statusText = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(props.count);
|
||||
const timer = ref(null);
|
||||
const autoStartPending = ref(false);
|
||||
const autoStartTriggered = ref(false);
|
||||
let autoStartTimer = null;
|
||||
const DISTANCE_PASSED_AUDIO_KEY = "站距合格,靶纸正确";
|
||||
const AUTO_START_TIMEOUT_MS = 6000;
|
||||
|
||||
const clearAutoStartTimer = () => {
|
||||
if (!autoStartTimer) return;
|
||||
clearTimeout(autoStartTimer);
|
||||
autoStartTimer = null;
|
||||
};
|
||||
|
||||
const triggerAutoStart = () => {
|
||||
if (!autoStartPending.value || autoStartTriggered.value) return;
|
||||
clearAutoStartTimer();
|
||||
autoStartPending.value = false;
|
||||
autoStartTriggered.value = true;
|
||||
emit("passed");
|
||||
};
|
||||
|
||||
const onAudioEnded = (key) => {
|
||||
if (key === DISTANCE_PASSED_AUDIO_KEY) triggerAutoStart();
|
||||
};
|
||||
|
||||
const updateTimer = (value) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭测试距离");
|
||||
audioManager.play("请射箭!测试站距与靶纸");
|
||||
if (props.isBattle) {
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
@@ -46,23 +80,31 @@ onMounted(() => {
|
||||
}, 1000);
|
||||
}
|
||||
uni.$on("update-timer", updateTimer);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
clearAutoStartTimer();
|
||||
uni.$off("update-timer", updateTimer);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
});
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
const rawDistance = Number(msg.shootData?.distance);
|
||||
if (autoStartPending.value || autoStartTriggered.value) return;
|
||||
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||
distance.value = Number.isFinite(rawDistance)
|
||||
? Number((rawDistance / 100).toFixed(2))
|
||||
: 0;
|
||||
if (rawDistance === 0) {
|
||||
audioManager.play("未发现靶纸,请瞄准靶纸射箭");
|
||||
} else if (distance.value >= 5) audioManager.play("距离合格");
|
||||
else audioManager.play("距离不足");
|
||||
statusText.value = getDistanceCheckText(msg.shootData);
|
||||
const audioKey = getDistanceCheckAudioKey(msg.shootData);
|
||||
const validation = getShootValidation(msg.shootData);
|
||||
if (props.autoStart && validation.distanceOk && validation.targetOk) {
|
||||
autoStartPending.value = true;
|
||||
autoStartTimer = setTimeout(triggerAutoStart, AUTO_START_TIMEOUT_MS);
|
||||
}
|
||||
audioManager.play(audioKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,13 +142,12 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<view class="warnning-text">
|
||||
<view class="target-tip">当前靶纸为<text class="text-yellow">{{ targetType }}cm</text>全环靶</view>
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离<text class="text-yellow">{{ distance }}</text>米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
<text v-else>请调整站位</text>
|
||||
<block v-if="statusText">
|
||||
<text v-if="distance > 0">当前距离<text class="text-yellow">{{ distance }}</text>米</text>
|
||||
<text>{{ statusText }}</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>请射箭,测试站距</text>
|
||||
<text>请射箭,测试站距与靶纸</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="user-row">
|
||||
|
||||
@@ -10,7 +10,10 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import TestDistance from "./components/TestDistance.vue";
|
||||
import BubbleTip from "./components/BubbleTip.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import audioManager, {
|
||||
getPrecisionShotAudioKeys,
|
||||
getTrainingStartAudioKey,
|
||||
} from "@/audioManager";
|
||||
|
||||
import {
|
||||
createPractiseV2API,
|
||||
@@ -38,6 +41,7 @@ const { user } = storeToRefs(store);
|
||||
|
||||
const sound = ref(true);
|
||||
const start = ref(false);
|
||||
const practiceStarting = ref(false);
|
||||
const pageStages = Object.freeze({
|
||||
DISTANCE: "distance",
|
||||
SHOOTING: "shooting",
|
||||
@@ -466,16 +470,11 @@ const buildShootResultAudioKeys = (message = {}) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = [
|
||||
arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶",
|
||||
];
|
||||
if (arrow.angle !== null && arrow.angle !== undefined) {
|
||||
keys.push(`向${getDirectionText(arrow.angle)}调整`);
|
||||
}
|
||||
if (arrow.threeConsecutive10Rings === true) {
|
||||
keys.push("tententen");
|
||||
}
|
||||
return keys;
|
||||
const directionText =
|
||||
arrow.angle !== null && arrow.angle !== undefined
|
||||
? getDirectionText(arrow.angle)
|
||||
: "";
|
||||
return getPrecisionShotAudioKeys(arrow, directionText);
|
||||
};
|
||||
|
||||
const playAudioKeysAndWait = (keys) => {
|
||||
@@ -790,6 +789,7 @@ const connectPracticeServer = ({
|
||||
requestPracticeInfoOnOpen: true,
|
||||
appHideResumable: appHideResumable.value,
|
||||
practiceEndAudioKey: "练习结束",
|
||||
trainingType: trainingType.value,
|
||||
});
|
||||
connectionClosed.value = false;
|
||||
return true;
|
||||
@@ -1022,9 +1022,17 @@ onLoad((options = {}) => {
|
||||
};
|
||||
practiseId.value = trainingContext.practiceId || "";
|
||||
serverAddr.value = trainingContext.serverAddr || "";
|
||||
|
||||
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
|
||||
const warmupKeys = [startAudioKey];
|
||||
if (trainingParams.value.type === "precision") {
|
||||
warmupKeys.push("Bingo命中目标", "未命中");
|
||||
}
|
||||
void audioManager.warmKeys(warmupKeys.filter(Boolean));
|
||||
});
|
||||
|
||||
const onReady = async () => {
|
||||
if (practiceStarting.value) return;
|
||||
if (
|
||||
!practiseId.value ||
|
||||
practiceEnded.value ||
|
||||
@@ -1038,6 +1046,7 @@ const onReady = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
practiceStarting.value = true;
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
@@ -1051,12 +1060,17 @@ const onReady = async () => {
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
setPracticeAppHideResumable(true);
|
||||
audioManager.play("练习开始");
|
||||
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
|
||||
audioManager.play(
|
||||
getTrainingStartAudioKey(trainingType.value, "练习开始")
|
||||
);
|
||||
} catch (error) {
|
||||
start.value = false;
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
setPracticeAppHideResumable(true);
|
||||
throw error;
|
||||
} finally {
|
||||
practiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1323,6 +1337,8 @@ onBeforeUnmount(() => {
|
||||
<TestDistance
|
||||
v-if="isDistanceStage"
|
||||
:targetType="practiceInfo.targetType"
|
||||
:autoStart="true"
|
||||
@passed="onReady"
|
||||
/>
|
||||
<view v-else-if="isShootingStage" class="shooting-layout">
|
||||
<view class="shooting-fixed">
|
||||
|
||||
@@ -24,6 +24,11 @@ const toFristTryPage = async () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
const toCoinPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/coin/nearby-stores",
|
||||
});
|
||||
};
|
||||
const toBeVipPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
@@ -108,6 +113,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
|
||||
<text v-if="user.trio > 0" :style="{ color: '#259249' }">已完成</text>
|
||||
<text v-else :style="{ color: '#CC311F' }">未完成</text>
|
||||
</UserItem>
|
||||
<UserItem title="我的金币" :onClick="toCoinPage" />
|
||||
<UserItem title="会员" :onClick="toBeVipPage">
|
||||
<view
|
||||
v-if="user.sVip === true"
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -329,18 +329,70 @@ export const getDirectionText = (angle = 0) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 正式射箭阶段的距离单位为厘米,按距离异常类型选择对应语音。
|
||||
export const getInvalidShotAudioKey = (shootData) => {
|
||||
if (!shootData || typeof shootData !== "object") return "射击无效";
|
||||
// protobuf 会省略值为 0 的标量字段;消息体存在且距离缺失时按 0 处理。
|
||||
// 射箭数据中的距离单位为厘米;旧版本会明确上报 target_ok=true。
|
||||
export const getShootValidation = (shootData) => {
|
||||
if (!shootData || typeof shootData !== "object") {
|
||||
return {
|
||||
rawDistance: 0,
|
||||
distance: 0,
|
||||
distanceOk: false,
|
||||
targetOk: false,
|
||||
targetDetected: false,
|
||||
};
|
||||
}
|
||||
|
||||
const rawDistance = Number(shootData.distance ?? shootData.dst ?? 0);
|
||||
if (!Number.isFinite(rawDistance)) return "射击无效";
|
||||
if (rawDistance < 0) return "射箭无效,靶纸错误";
|
||||
if (rawDistance === 0) return "射箭无效,未识别到靶纸";
|
||||
if (rawDistance / 100 < 5) return "射箭无效,距离不足";
|
||||
const finiteDistance = Number.isFinite(rawDistance) ? rawDistance : 0;
|
||||
const targetFlag = shootData.targetOk ?? shootData.target_ok;
|
||||
const targetDetected = finiteDistance !== 0;
|
||||
const targetOk = finiteDistance > 0 && targetFlag === true;
|
||||
|
||||
return {
|
||||
rawDistance: finiteDistance,
|
||||
distance: Math.max(0, finiteDistance / 100),
|
||||
// 旧固件以负距离表示靶纸错误,此时只提示靶纸问题,不叠加距离不足。
|
||||
distanceOk: finiteDistance < 0 || finiteDistance >= 500,
|
||||
targetOk: targetDetected && targetOk,
|
||||
targetDetected,
|
||||
};
|
||||
};
|
||||
|
||||
// 测距阶段提示音:按站距与靶纸组合播放新版测距语音。
|
||||
export const getDistanceCheckAudioKey = (shootData) => {
|
||||
const result = getShootValidation(shootData);
|
||||
if (!result.targetDetected) return "未识别到靶纸,请瞄准靶纸射箭";
|
||||
if (!result.distanceOk && !result.targetOk) {
|
||||
return "站距过近,靶纸错误";
|
||||
}
|
||||
if (!result.distanceOk) return "站距过近,靶纸正确";
|
||||
if (!result.targetOk) return "站距合格,靶纸错误";
|
||||
return "站距合格,靶纸正确";
|
||||
};
|
||||
|
||||
export const getDistanceCheckText = (shootData) => {
|
||||
const result = getShootValidation(shootData);
|
||||
if (!result.targetDetected) return "未识别到靶纸,请瞄准靶纸射箭";
|
||||
if (result.rawDistance < 0) return "靶纸错误";
|
||||
return `${result.distanceOk ? "站距合格" : "站距过近"},${
|
||||
result.targetOk ? "靶纸正确" : "靶纸错误"
|
||||
}`;
|
||||
};
|
||||
|
||||
// 正式射箭阶段的距离和靶纸异常类型选择对应语音。
|
||||
export const getInvalidShotAudioKey = (shootData) => {
|
||||
const result = getShootValidation(shootData);
|
||||
if (!result.targetDetected) return "射箭无效,未识别到靶纸";
|
||||
if (!result.distanceOk && !result.targetOk) {
|
||||
return "射箭无效,距离不足且靶纸错误";
|
||||
}
|
||||
if (!result.targetOk) return "射箭无效,靶纸错误";
|
||||
if (!result.distanceOk) return "射箭无效,距离不足";
|
||||
return "射击无效";
|
||||
};
|
||||
|
||||
export const getInvalidShotText = (shootData) =>
|
||||
getInvalidShotAudioKey(shootData);
|
||||
|
||||
export const wxLogin = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
|
||||
@@ -7,8 +7,8 @@ const { Reader, Writer } = protobuf;
|
||||
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
|
||||
// <match-schema-generated>
|
||||
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
|
||||
// 来源:src/utils/match.min.js(sha256: 150812589c2e6f7a)
|
||||
// 协议命名空间:rpc;消息数:12;字段数:151
|
||||
// 来源:src/utils/match.min.js(sha256: a7ab92ae43e405e7)
|
||||
// 协议命名空间:rpc;消息数:12;字段数:153
|
||||
|
||||
export const ServerMessageType = {
|
||||
SERVER_MSG_UNKNOWN: 0,
|
||||
@@ -51,6 +51,7 @@ const SCHEMAS = {
|
||||
8: { name: "distance", kind: "float" },
|
||||
9: { name: "three_consecutive_10_rings", kind: "bool" },
|
||||
10: { name: "ok", kind: "bool" },
|
||||
11: { name: "target_ok", kind: "bool" },
|
||||
},
|
||||
MatchShootList: {
|
||||
1: { name: "items", kind: "message", type: "MatchShoot", repeated: true },
|
||||
@@ -139,6 +140,7 @@ const SCHEMAS = {
|
||||
7: { name: "device_id", kind: "string" },
|
||||
8: { name: "shoot_id", kind: "string" },
|
||||
9: { name: "three_consecutive_10_rings", kind: "bool" },
|
||||
10: { name: "target_ok", kind: "bool" },
|
||||
},
|
||||
PracticeInfo: {
|
||||
1: { name: "id", kind: "int64" },
|
||||
|
||||