7 Commits
37 changed files with 346 additions and 1094 deletions
-8
View File
@@ -243,14 +243,6 @@
{
"type": "folder",
"value": "static/training-difficulty-design"
},
{
"type": "folder",
"value": "static/coin"
},
{
"type": "folder",
"value": "static/home-device"
}
],
"include": []
-15
View File
@@ -770,21 +770,6 @@ export const getGiftDetailAPI = (id) => {
);
};
// 分页获取启用门店及当前用户在各店的会员状态。
export const getStoreVipListAPI = ({page = 1, pageSize = 20} = {}) => {
return request("GET", "/store/list", {page, pageSize});
};
// 查询当前用户在指定门店的会员状态及权益剩余次数。
export const getStoreBenefitAPI = (storeId) => {
return request("GET", "/store/benefit", {storeId});
};
// 获取体验中心 VIP 介绍纯文本,无需登录。
export const getStoreVipIntroAPI = () => {
return request("GET", "/store/vip-intro");
};
// 根据用户定位分页获取附近门店。
export const getNearbyStoresAPI = ({
longitude,
-8
View File
@@ -18,10 +18,6 @@ const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。
export const getTrainingEndAudioKey = (completed) =>
completed === true ? "通关成功" : "通关失败";
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
if (!shootData || typeof shootData !== "object") return [];
const ring = Number(shootData.ring);
@@ -205,10 +201,6 @@ export const audioFils = {
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
练习结束:
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
通关成功:
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3",
通关失败:
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3",
射箭声音:
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
命中:
+2 -6
View File
@@ -7,13 +7,9 @@ const tabs = [
function handleTabClick(index) {
if (index === 0) {
uni.showToast({
title: "功能尚未开启",
icon: "none",
uni.navigateTo({
url: "/pages/member/be-vip",
});
// uni.navigateTo({
// url: "/pages/member/be-vip",
// });
}
if (index === 1) {
uni.navigateTo({
+1 -3
View File
@@ -164,8 +164,6 @@ const defaultHighlightStyle = {
color: "rgba(255, 228, 0, 0.6)",
strokeColor: "rgba(254, 216, 71, 0.82)",
lineWidthRatio: 0.003,
// 在最小线宽计算后应用倍数,确保不同靶面尺寸下按比例加粗。
lineWidthScale: 1,
};
// 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。
@@ -321,7 +319,7 @@ const drawSectorHighlight = (
(angles.endAngle - angles.startAngle) * safeRevealProgress,
style.color,
style.strokeColor,
Math.max(1, targetRadius * style.lineWidthRatio) * style.lineWidthScale
Math.max(1, targetRadius * style.lineWidthRatio)
);
});
};
-8
View File
@@ -294,14 +294,6 @@
{
"type" : "folder",
"value" : "static/training-difficulty-design"
},
{
"type" : "folder",
"value" : "static/coin"
},
{
"type" : "folder",
"value" : "static/home-device"
}
]
},
-5
View File
@@ -18,7 +18,6 @@ import {
getPrecisionShotAudioKeys,
getRhythmShotAudioKeys,
getStabilityShotAudioKeys,
getTrainingEndAudioKey,
getTrainingStartAudioKey,
STABILITY_ENERGY_50_AUDIO_KEY,
STABILITY_ENERGY_70_AUDIO_KEY,
@@ -604,10 +603,6 @@ function getAckAudioKeys(message, businessMessage) {
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
return ["比赛结束"];
case ServerMessageType.SERVER_MSG_PRACTICE_END:
// 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。
if (currentContext?.practiceEndAudioKey === "练习结束") {
return [getTrainingEndAudioKey(businessMessage?.completed)];
}
return [currentContext?.practiceEndAudioKey || "比赛结束"];
case ServerMessageType.SERVER_MSG_CHECK:
return getTestDistanceAudioKeys(businessMessage?.shootData);
+151 -169
View File
@@ -1,5 +1,5 @@
{
"pages": [
{
"pages": [
{
"path": "pages/index"
},
@@ -9,136 +9,118 @@
{
"path": "pages/friend-battle"
},
{
"path": "pages/point-book"
},
{
"path": "pages/point-book-rank"
},
{
"path": "pages/my-like-list"
},
{
"path": "pages/audio-test"
},
{
"path": "pages/calibration"
},
{
"path": "pages/about-us"
},
{
"path": "pages/webview",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/melee-battle"
},
{
"path": "pages/battle-result"
},
{
"path": "pages/friend-battle-result"
},
{
"path": "pages/point-book-edit"
},
{
"path": "pages/point-book-create"
},
{
"path": "pages/point-book-list"
},
{
"path": "pages/point-book-detail"
},
{
"path": "pages/point-book-detail-share"
},
{
"path": "pages/match-page"
},
{
"path": "pages/my-device"
},
{
"path": "pages/device-intro"
},
{
"path": "pages/user"
},
{
"path": "pages/grade-intro"
},
{
"path": "pages/rank-intro"
},
{
"path": "pages/my-growth"
},
{
"path": "pages/first-try"
},
{
"path": "pages/practise"
},
{
"path": "pages/practise-one"
},
{
"path": "pages/practise-two"
},
{
"path": "pages/battle-room"
},
{
"path": "pages/ranking"
},
{
"path": "pages/rank-list"
},
{
"path": "pages/match-detail"
},
{
"path": "pages/melee-bow-data"
},
{
"path": "pages/point-book"
},
{
"path": "pages/point-book-rank"
},
{
"path": "pages/my-like-list"
},
{
"path": "pages/audio-test"
},
{
"path": "pages/about-us"
},
{
"path": "pages/webview",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/melee-battle"
},
{
"path": "pages/battle-result"
},
{
"path": "pages/friend-battle-result"
},
{
"path": "pages/point-book-edit"
},
{
"path": "pages/point-book-create"
},
{
"path": "pages/point-book-list"
},
{
"path": "pages/point-book-detail"
},
{
"path": "pages/point-book-detail-share"
},
{
"path": "pages/match-page"
},
{
"path": "pages/user"
},
{
"path": "pages/grade-intro"
},
{
"path": "pages/rank-intro"
},
{
"path": "pages/my-growth"
},
{
"path": "pages/first-try"
},
{
"path": "pages/practise"
},
{
"path": "pages/practise-one"
},
{
"path": "pages/practise-two"
},
{
"path": "pages/battle-room"
},
{
"path": "pages/ranking"
},
{
"path": "pages/rank-list"
},
{
"path": "pages/match-detail"
},
{
"path": "pages/melee-bow-data"
},
{
"path": "pages/mine-bow-data"
},
{
"path": "pages/ota-wifi",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"backgroundColor": "@bgColor",
"backgroundColorBottom": "@bgColorBottom",
"backgroundColorTop": "@bgColorTop",
"backgroundTextStyle": "@bgTxtStyle",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarTitleText": "Uni Creator",
"navigationStyle": "custom",
"enablePullDownRefresh": false
},
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"globalStyle": {
"backgroundColor": "@bgColor",
"backgroundColorBottom": "@bgColorBottom",
"backgroundColorTop": "@bgColorTop",
"backgroundTextStyle": "@bgTxtStyle",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarTitleText": "Uni Creator",
"navigationStyle": "custom",
"enablePullDownRefresh": false
},
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"subPackages": [
{
"root": "pages/coin",
"pages": [
{
"path": "vip-intro"
},
{
"path": "index"
},
@@ -196,48 +178,48 @@
},
{
"root": "pages/member",
"pages": [
{
"path": "orders"
},
{
"path": "order-detail"
},
{
"path": "be-vip"
},
{
"path": "vip-intro"
},
{
"path": "agreement"
}
]
},
{
"root": "pages/team-battle",
"pages": [
{
"path": "index"
},
{
"path": "team-bow-data"
}
]
},
{
"root": "pages/training",
"pages": [
{
"path": "index"
},
{
"path": "difficulty"
},
{
"path": "practise-one"
}
]
}
]
}
"pages": [
{
"path": "orders"
},
{
"path": "order-detail"
},
{
"path": "be-vip"
},
{
"path": "vip-intro"
},
{
"path": "agreement"
}
]
},
{
"root": "pages/team-battle",
"pages": [
{
"path": "index"
},
{
"path": "team-bow-data"
}
]
},
{
"root": "pages/training",
"pages": [
{
"path": "index"
},
{
"path": "difficulty"
},
{
"path": "practise-one"
}
]
}
]
}
@@ -38,12 +38,13 @@ defineProps({
display: flex;
align-items: center;
justify-content: center;
width: 100%;
width: 706rpx;
height: 60rpx;
margin-top: 0;
border: 2rpx solid rgba(255, 217, 71, 0.25);
border-radius: 12rpx;
box-sizing: border-box;
background: linear-gradient(90deg, transparent, rgba(4, 4, 4, 0.7), transparent);
background-color: rgba(255, 217, 71, 0.06);
}
.balance-item {
@@ -68,7 +69,7 @@ defineProps({
.balance-list__line {
width: 2rpx;
height: 16rpx;
height: 28rpx;
background-color: rgba(255, 255, 255, 0.5);
margin: 0 22rpx;
}
+4 -10
View File
@@ -10,13 +10,11 @@ const props = defineProps({
type: String,
default: "",
},
switchable: { type: Boolean, default: false },
onBack: {
type: Function,
default: null,
},
});
const emit = defineEmits(["select-store"]);
</script>
<template>
@@ -32,20 +30,16 @@ const emit = defineEmits(["select-store"]);
>
{{ title }}
</text>
<view v-if="subtitle" class="coin-header__store" @click="switchable && emit('select-store')">
<text class="coin-header__subtitle">{{ subtitle }}</text>
<image v-if="switchable" class="coin-header__switch" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-switch.png" mode="aspectFit" />
</view>
<text v-if="subtitle" class="coin-header__subtitle">
{{ subtitle }}
</text>
</view>
</template>
</Header>
</view>
</template>
<style scoped lang="scss">
.coin-header__store { display: flex; align-items: center; max-width: 430rpx; }
.coin-header__store .coin-header__subtitle { overflow: hidden; text-overflow: ellipsis; }
.coin-header__switch { flex-shrink: 0; width: 28rpx; height: 28rpx; margin-left: 10rpx; }
<style scoped>
.coin-header {
position: sticky;
top: 0;
@@ -2,7 +2,7 @@
import { computed, ref } from "vue";
const DEFAULT_PRODUCT_IMAGE =
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-item.png";
const props = defineProps({
images: {
type: Array,
@@ -51,9 +51,6 @@ const productImage = computed(() => {
height: 312rpx;
overflow: hidden;
border-radius: 12rpx;
background-image: url("https://static.shelingxingqiu.com/shootmini/static/coin/product-card-bg.png");
background-size: 100% 100%;
background-repeat: no-repeat;
}
.product-card__image {
@@ -1,80 +0,0 @@
<script setup>
defineProps({
benefit: {
type: Object,
required: true,
},
});
</script>
<template>
<!-- 与会员卡共用剩余次数,接口缺失时显示占位。 -->
<view class="benefit-card">
<text class="benefit-card__badge">{{ benefit.remaining }}次</text>
<image class="benefit-card__icon" :src="benefit.icon" mode="aspectFit" />
<view class="benefit-card__content">
<text class="benefit-card__title">{{ benefit.title }}</text>
<text class="benefit-card__description">{{ benefit.description }}</text>
</view>
</view>
</template>
<style scoped lang="scss">
.benefit-card {
position: relative;
display: flex;
align-items: center;
min-height: 176rpx;
margin-bottom: 24rpx;
padding: 28rpx 28rpx 28rpx 56rpx;
box-sizing: border-box;
border-radius: 20rpx;
background: linear-gradient(180deg, #080808, rgba(152, 124, 84, 0.4));
}
// .benefit-card:last-child {
// margin-bottom: 0;
// }
.benefit-card__badge {
position: absolute;
top: 0;
left: 0;
width: 64rpx;
height: 32rpx;
border-radius: 16rpx 0 16rpx 0;
background: linear-gradient(135deg, #ffd19a, #a17636);
color: #ffffff;
font-size: 22rpx;
line-height: 30rpx;
text-align: center;
}
.benefit-card__icon {
flex-shrink: 0;
width: 120rpx;
height: 120rpx;
margin-right: 38rpx;
}
.benefit-card__content {
flex: 1;
min-width: 0;
}
.benefit-card__title {
display: block;
color: #fae6bc;
font-size: 30rpx;
line-height: 42rpx;
font-weight: 500;
}
.benefit-card__description {
display: block;
margin-top: 10rpx;
color: #ffffff;
font-size: 22rpx;
line-height: 30rpx;
}
</style>
@@ -1,61 +0,0 @@
<script setup>
defineProps({
tabs: {
type: Array,
default: () => [],
},
active: {
type: String,
default: "vip",
},
});
const emit = defineEmits(["select"]);
</script>
<template>
<!-- 业务 Tab 只切换内容,不接管系统导航。 -->
<view class="section-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="section-tabs__item"
:class="{ 'section-tabs__item--active': active === tab.key }"
hover-class="section-tabs__item--pressed"
@click="emit('select', tab.key)"
>
<text>{{ tab.label }}</text>
</view>
</view>
</template>
<style scoped lang="scss">
.section-tabs {
display: flex;
justify-content: space-between;
padding: 20rpx 40rpx 0;
box-sizing: border-box;
}
.section-tabs__item {
display: flex;
align-items: center;
justify-content: center;
width: 326rpx;
height: 68rpx;
border-radius: 34rpx;
color: #999999;
background: #000000;
font-size: 28rpx;
line-height: 40rpx;
}
.section-tabs__item--active {
color: #ffffff;
background: linear-gradient(135deg, #ffd19a, #a17636);
}
.section-tabs__item--pressed {
opacity: 0.8;
}
</style>
-135
View File
@@ -1,135 +0,0 @@
<script setup>
defineProps({
membership: {
type: Object,
required: true,
},
benefits: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["introduction"]);
</script>
<template>
<view class="vip-card" :class="{ 'vip-card--active': membership.active }">
<!-- 切图仅含静态字效与徽章,次数、有效期和状态独立渲染。 -->
<image class="vip-card__background" :src="membership.cardImage" mode="aspectFit" />
<view class="vip-card__benefits">
<view v-for="benefit in benefits" :key="benefit.key" class="vip-card__benefit">
<text class="vip-card__label">{{ benefit.title }}</text>
<view class="vip-card__count">
<text class="vip-card__number">{{ membership.remaining[benefit.key] }}</text>
<text class="vip-card__unit">次</text>
</view>
</view>
</view>
<text class="vip-card__status">
{{ membership.active ? `有效期至${membership.expiresAt}` : "未开通" }}
</text>
<view class="vip-card__introduction" @click="emit('introduction')">
<text>查看更多关于体验中心VIP的介绍 &gt;</text>
</view>
</view>
</template>
<style scoped lang="scss">
.vip-card {
position: relative;
width: 750rpx;
height: 288rpx;
color: #333333;
}
.vip-card__background {
position: absolute;
top: 0;
left: 0;
width: 750rpx;
height: 288rpx;
}
.vip-card__benefits {
position: absolute;
top: 124rpx;
left: 70rpx;
display: flex;
justify-content: space-between;
width: 400rpx;
}
.vip-card__benefit {
width: 120rpx;
text-align: center;
}
.vip-card__label {
display: block;
font-size: 20rpx;
line-height: 28rpx;
white-space: nowrap;
opacity: 0.8;
}
.vip-card__count {
display: flex;
align-items: baseline;
justify-content: center;
position: relative;
height: 44rpx;
}
.vip-card__count::before {
content: "";
position: absolute;
left: 16rpx;
bottom: 4rpx;
width: 88rpx;
height: 12rpx;
border-radius: 8rpx;
background: #ffd947;
}
.vip-card__number {
position: relative;
font-size: 40rpx;
line-height: 48rpx;
font-weight: 500;
}
.vip-card__unit {
position: relative;
margin-left: 2rpx;
font-size: 22rpx;
line-height: 30rpx;
opacity: 0.8;
}
.vip-card__status {
position: absolute;
top: 212rpx;
left: 496rpx;
width: 232rpx;
color: #888888;
font-size: 22rpx;
line-height: 30rpx;
text-align: center;
white-space: nowrap;
}
.vip-card--active .vip-card__status {
color: #b47d42;
}
.vip-card__introduction {
position: absolute;
left: 74rpx;
bottom: 16rpx;
padding: 6rpx 0;
font-size: 22rpx;
line-height: 30rpx;
opacity: 0.8;
}
</style>
@@ -1,62 +0,0 @@
<script setup>
const props = defineProps({
active: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["open"]);
const handleOpen = () => {
if (!props.active) emit("open");
};
</script>
<template>
<!-- 当前门店已开通时仅展示状态,未开通时提供线下开通提醒。 -->
<view class="vip-footer">
<text class="vip-footer__price">特惠价格:9.9元/90天</text>
<view class="vip-footer__status" @click="handleOpen">
<image class="vip-footer__badge" src="https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-badge.png" mode="aspectFit" />
<text>{{ active ? "您已开通VIP" : "开通方式:请前往线下门店联系工作人员开通" }}</text>
</view>
</view>
</template>
<style scoped lang="scss">
.vip-footer {
margin-top: 84rpx;
padding: 0 24rpx 22rpx;
box-sizing: border-box;
}
.vip-footer__price {
display: block;
color: #fae6bc;
font-size: 40rpx;
line-height: 56rpx;
font-weight: 500;
text-align: center;
}
.vip-footer__status {
display: flex;
align-items: center;
justify-content: center;
min-height: 90rpx;
margin-top: 12rpx;
padding: 8rpx 28rpx;
box-sizing: border-box;
border-radius: 46rpx;
color: #333333;
background: #ffd947;
font-size: 26rpx;
line-height: 36rpx;
}
.vip-footer__badge {
flex-shrink: 0;
width: 80rpx;
height: 74rpx;
margin-right: 4rpx;
}
</style>
-28
View File
@@ -1,4 +1,3 @@
// 金币快捷入口保持原有路由与图标配置。
export const quickMenus = [
{
key: "rules",
@@ -21,30 +20,3 @@ export const quickMenus = [
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
},
];
// 体验中心业务 Tab。
export const storeSections = [
{ key: "vip", label: "体验中心VIP" },
{ key: "coin", label: "我的金币" },
];
export const storeVipBenefits = [
{
key: "trial",
title: "免费体验权益",
description: "使用该权益可免费体验智能弓箭30分钟1次,加入官方社群还可以额外获赠1次。",
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-trial.png",
},
{
key: "discount",
title: "特惠购买权益",
description: "使用该权益可以19.9元的特惠价格购买30分钟体验服务1次。",
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-discount.png",
},
{
key: "drink",
title: "到店赠饮权益",
description: "使用该权益每次到店后可获得免费赠送的指定饮品一支。",
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-benefit-drink.png",
},
];
+30 -148
View File
@@ -1,53 +1,14 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ModalDialog from "@/components/ModalDialog.vue";
import CoinHeader from "./components/CoinHeader.vue";
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
import RewardProductCard from "./components/RewardProductCard.vue";
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
import { quickMenus, storeSections, storeVipBenefits } from "./data";
import StoreSectionTabs from "./components/StoreSectionTabs.vue";
import StoreVipCard from "./components/StoreVipCard.vue";
import StoreBenefitCard from "./components/StoreBenefitCard.vue";
import StoreVipFooter from "./components/StoreVipFooter.vue";
import { getSelectedExperienceStore, rememberExperienceStore, loadStoreMembership } from "@/utils/storeVip";
const activeTab = ref("vip");
const membership = ref(null);
const vipLoading = ref(false);
const vipError = ref("");
const displayedBenefits = computed(() => storeVipBenefits.map((benefit) => ({
...benefit, remaining: membership.value?.remaining[benefit.key] ?? "--",
})));
const loadMembership = async () => {
if (!selectedStoreId.value || vipLoading.value) return;
vipLoading.value = true;
vipError.value = "";
membership.value = null;
try {
const result = await loadStoreMembership(selectedStoreId.value);
membership.value = result;
if (result.name) selectedStoreName.value = result.name;
rememberExperienceStore(selectedStoreId.value, selectedStoreName.value);
} catch (error) {
vipError.value = "会员信息加载失败,请点击重试或切换门店";
console.error("加载门店会员失败", error);
} finally {
vipLoading.value = false;
}
};
const selectStore = () => uni.redirectTo({ url: `/pages/coin/nearby-stores?tab=${activeTab.value}` });
const selectTab = (tab) => {
if (storeSections.some((item) => item.key === tab)) activeTab.value = tab;
};
const showVipIntroduction = () => uni.navigateTo({ url: "/pages/coin/vip-intro" });
const vipOpeningVisible = ref(false);
const showVipOpening = () => { vipOpeningVisible.value = true; };
const closeVipOpening = () => { vipOpeningVisible.value = false; };
import { quickMenus } from "./data";
const PAGE_SIZE = 20;
const coinSummary = ref({
@@ -58,8 +19,8 @@ const selectedStoreId = ref("");
const selectedStoreName = ref("");
const rewardProducts = ref([]);
const productPage = ref(0);
const productTotal = ref(0);
const productLoading = ref(false);
const productError = ref(false);
const productNoMore = ref(false);
const loadCoinSummary = async () => {
@@ -79,7 +40,6 @@ const loadProducts = async ({ reset = false } = {}) => {
const nextPage = reset ? 1 : productPage.value + 1;
productLoading.value = true;
productError.value = false;
if (reset) productNoMore.value = false;
try {
@@ -105,6 +65,7 @@ const loadProducts = async ({ reset = false } = {}) => {
const totalCount = Number(result?.totalCount ?? result?.total);
const responsePageSize =
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
productTotal.value = Number.isFinite(totalCount) ? totalCount : 0;
if (Number.isFinite(pageCount) && pageCount >= 0) {
productNoMore.value = productPage.value >= pageCount;
@@ -118,7 +79,6 @@ const loadProducts = async ({ reset = false } = {}) => {
rewardProducts.value = [];
productPage.value = 0;
}
productError.value = true;
console.error("加载礼品列表失败", error);
} finally {
productLoading.value = false;
@@ -155,7 +115,7 @@ const decodeRouteText = (value = "") => {
const onMenuSelect = (menu) => {
if (menu.key === "nearbyStores") {
uni.redirectTo({
url: `/pages/coin/nearby-stores?tab=${activeTab.value}`,
url: "/pages/coin/nearby-stores",
});
return;
}
@@ -183,104 +143,50 @@ const toProductDetail = (product) => {
};
onLoad((options = {}) => {
activeTab.value = options.tab === "coin" ? "coin" : "vip";
const previous = getSelectedExperienceStore();
const storeId = String(options.storeId || previous?.id || "");
const storeId = String(options.storeId || "");
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
selectStore();
uni.redirectTo({
url: "/pages/coin/nearby-stores",
});
return;
}
selectedStoreId.value = storeId;
selectedStoreName.value = options.storeId
? decodeRouteText(options.storeName)
: previous?.name || "";
rememberExperienceStore(storeId, selectedStoreName.value);
loadProducts({ reset: true });
});
// 从介绍、商品等页面返回时刷新会员和余额。
onShow(() => {
if (!selectedStoreId.value) return;
loadMembership();
selectedStoreId.value = storeId;
selectedStoreName.value = decodeRouteText(options.storeName);
loadCoinSummary();
loadProducts({ reset: true });
});
</script>
<template>
<!-- 标题固定,门店切换复用原有附近门店页面。 -->
<Container :bgType="6" bgColor="#22222e" :isHome="true" @scrolltolower="activeTab === 'coin' && loadProducts()">
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
<template #header>
<CoinHeader title="线下体验中心" :subtitle="selectedStoreName || '选择门店'" switchable @select-store="selectStore" />
<CoinHeader title="我的金币" :subtitle="selectedStoreName" />
</template>
<view class="coin-page">
<StoreSectionTabs
:tabs="storeSections"
:active="activeTab"
@select="selectTab"
<CoinBalancePanel
:cumulative="coinSummary.cumulative"
:available="coinSummary.available"
/>
<!-- 卡片与权益列表共用当前门店的会员数据。 -->
<view v-if="activeTab === 'vip'" class="vip-section">
<view v-if="vipLoading" class="page-status">会员信息加载中...</view>
<view v-else-if="vipError" class="page-status" @click="loadMembership">{{ vipError }}</view>
<StoreVipCard
v-else-if="membership"
:membership="membership"
:benefits="storeVipBenefits"
@introduction="showVipIntroduction"
/>
<view class="vip-section__content">
<text class="vip-section__description">
门店体验中心VIP是为热爱射箭运动的用户专属打造的VIP服务,开通后即可享受以下三大专属权益:
</text>
<view class="vip-section__benefits">
<StoreBenefitCard
v-for="benefit in displayedBenefits"
:key="benefit.key"
:benefit="benefit"
/>
<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>
<StoreVipFooter v-if="membership" :active="membership.active" @open="showVipOpening" />
</view>
<!-- 保留原金币接口、分页与详情链路。 -->
<view v-else class="coin-section">
<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 v-if="productLoading" class="page-status">加载中...</view>
<view v-else-if="productError" class="page-status" @click="loadProducts()">加载失败,点击重试</view>
<view v-else-if="productNoMore" class="page-status">{{ rewardProducts.length ? '没有更多商品了~' : '暂无兑换商品~' }}</view>
</view>
</view>
</Container>
<ModalDialog
:show="vipOpeningVisible"
content="请前往线下门店体验中心,联系工作人员开通VIP。"
:show-cancel="false"
confirm-text="我已知晓"
:on-confirm="closeVipOpening"
/>
</template>
<style scoped lang="scss">
.page-status { padding: 30rpx; color: #cccccc; text-align: center; font-size: 26rpx; }
<style scoped>
.coin-page {
width: 100%;
min-height: 100%;
@@ -288,30 +194,6 @@ onShow(() => {
box-sizing: border-box;
}
.vip-section {
padding-top: 30rpx;
}
.vip-section__content {
padding: 0 28rpx;
}
.vip-section__description {
display: block;
margin-top: 50rpx;
color: #ffffff;
font-size: 24rpx;
line-height: 34rpx;
}
.vip-section__benefits {
margin-top: 30rpx;
}
.coin-section {
padding-top: 20rpx;
}
.reward-section {
padding: 20rpx 28rpx 40rpx;
box-sizing: border-box;
+2 -9
View File
@@ -6,9 +6,6 @@ import CoinHeader from "./components/CoinHeader.vue";
import LocationPermissionState from "./components/LocationPermissionState.vue";
import StoreCard from "./components/StoreCard.vue";
import { getNearbyStoresAPI } from "@/apis";
import { rememberExperienceStore } from "@/utils/storeVip";
const activeTab = ref("vip");
const PAGE_SIZE = 20;
const DEFAULT_STORE_IMAGE =
@@ -116,16 +113,12 @@ const selectStore = (store) => {
}
const storeName = encodeURIComponent(store?.name || "");
rememberExperienceStore(storeId, store?.name || "");
uni.redirectTo({
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}&tab=${activeTab.value}`,
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}`,
});
};
onLoad((options = {}) => {
activeTab.value = options.tab === "coin" ? "coin" : "vip";
locateAndLoadStores();
});
onLoad(locateAndLoadStores);
</script>
<template>
-48
View File
@@ -1,48 +0,0 @@
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import CoinHeader from "./components/CoinHeader.vue";
import { getStoreVipIntroAPI } from "@/apis";
const content = ref("");
const loading = ref(false);
const failed = ref(false);
const loadIntroduction = async () => {
if (loading.value) return;
loading.value = true;
failed.value = false;
try {
const result = await getStoreVipIntroAPI();
if (typeof result?.content !== "string") {
throw new Error("体验中心 VIP 介绍响应格式异常");
}
// 保留服务端原文与换行,空白内容单独展示提示。
content.value = result.content;
} catch (error) {
failed.value = true;
console.error("加载体验中心 VIP 介绍失败", error);
} finally {
loading.value = false;
}
};
onLoad(loadIntroduction);
</script>
<template>
<Container :bgType="6" bgColor="#22222e" :isHome="true">
<template #header><CoinHeader title="体验中心VIP介绍" /></template>
<view class="vip-intro">
<text v-if="loading">加载中...</text>
<text v-else-if="failed" @click="loadIntroduction">加载失败,点击重试</text>
<text v-else-if="content.trim()">{{ content }}</text>
<text v-else>暂无介绍内容</text>
</view>
</Container>
</template>
<style scoped lang="scss">
.vip-intro { padding: 32rpx 28rpx; color: #ffffff; font-size: 28rpx; line-height: 44rpx; white-space: pre-wrap; }
</style>
+5 -5
View File
@@ -447,12 +447,12 @@ onShow(async () => {
<text class="highlight-text">射灵智能弓箭</text>
<text>设备上的二维码</text>
</view>
<!-- <view class="benefit-copy">
<view class="benefit-copy">
<text>新设备首次绑定账号可获赠</text>
<text class="highlight-text">6个月射灵会员礼包</text>
<text>,</text>
<text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text>
</view> -->
</view>
<button class="help-link" hover-class="none" @click="showTip = true">
<text>没找到二维码或遇到问题&gt;</text>
</button>
@@ -866,7 +866,7 @@ onShow(async () => {
}
.help-link {
margin-top: 36rpx;
margin-top: 72rpx;
color: #ffd947;
font-size: 26rpx;
line-height: 36rpx;
@@ -874,8 +874,8 @@ onShow(async () => {
.product-link {
position: fixed;
bottom: 208rpx;
/* bottom: calc(env(safe-area-inset-bottom) + 104rpx); */
bottom: 104rpx;
bottom: calc(env(safe-area-inset-bottom) + 104rpx);
left: 0;
z-index: 2;
width: 100%;
+1 -20
View File
@@ -39,7 +39,6 @@ const connectMode = ref("secure"); // secure | open | manual
const connectError = ref("");
const connectStatusText = ref("");
const isSubmittingWifi = ref(false);
const wifiSuccessVisible = ref(false);
const keyboardHeight = ref(0);
// 控制密码输入框是否显示明文
const showPassword = ref(false);
@@ -365,13 +364,10 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
secure: connectMode.value === "secure" || !!password,
};
connectError.value = "";
currentState.value = STATES.CONNECTED;
void refreshConnectedWifiName();
if (fromFirmwareUpdate.value) {
currentState.value = STATES.CONNECTED;
openFirmwareCountdown();
} else {
// 保留配网输入界面,避免成功提示出现前先露出 WiFi 列表。
wifiSuccessVisible.value = true;
}
} catch (err) {
if (requestId === wifiConnectRequestId) {
@@ -386,13 +382,6 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
}
};
// 普通配网成功后,确认并收起输入界面,停留在当前 WiFi 页面。
const confirmWifiSuccess = () => {
if (!wifiSuccessVisible.value) return;
wifiSuccessVisible.value = false;
currentState.value = STATES.CONNECTED;
};
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
const joinNetwork = () => {
if (joinDisabled.value) return;
@@ -766,14 +755,6 @@ onUnmounted(() => {
</view>
</view>
<ModalDialog
:show="wifiSuccessVisible"
content="WIFI连接成功"
confirmText="确定"
:showCancel="false"
:onConfirm="confirmWifiSuccess"
></ModalDialog>
<ModalDialog
:show="countdownVisible"
title="WiFi连接成功"
@@ -546,7 +546,6 @@ onBeforeUnmount(() => {
:showCrosshair="false"
:showRingLabels="false"
:highlightOnly="true"
:highlightStyle="{ strokeColor: '#00fffc', lineWidthScale: 3 }"
:sectorCount="sectorCount"
:activeSector="activeSector"
:activeRings="activeRings"
@@ -8,7 +8,6 @@ import {
nextTick,
} from "vue";
import audioManager, {
getTrainingEndAudioKey,
getTrainingStartAudioKey,
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
} from "@/audioManager";
@@ -112,8 +111,6 @@ const props = defineProps({
},
});
const emit = defineEmits(["rhythm-round-complete"]);
const trainingTitleIconMap = Object.freeze({
base:
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
@@ -146,7 +143,6 @@ let rhythmCountdownTimer = null;
let rhythmTransitionTimer = null;
let rhythmServerClockOffsetMs = 0;
let rhythmSyncGeneration = 0;
let rhythmRoundCompletionTimer = null;
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
const isStabilityTraining = computed(
@@ -227,7 +223,7 @@ const rhythmTitle = computed(() => {
if (!validRhythmHitReq.value) {
return `在最后的${validRhythmShootTime.value}秒内射箭`;
}
return `在最后的${validRhythmShootTime.value}秒射箭并命中${validRhythmHitReq.value}环内`;
return `在最后的${validRhythmShootTime.value}秒并命中${validRhythmHitReq.value}环内`;
});
const displayName = computed(() => {
@@ -310,30 +306,6 @@ const getRhythmRoundRemainingMs = () => {
);
};
const clearRhythmRoundCompletionTimer = () => {
if (rhythmRoundCompletionTimer !== null) {
clearTimeout(rhythmRoundCompletionTimer);
rhythmRoundCompletionTimer = null;
}
};
// 与当前显示的倒计时一起计时;消息校准不重建此任务,避免同一轮重复计数。
const scheduleRhythmRoundCompletion = (remainingMs) => {
if (
rhythmRoundCompletionTimer !== null ||
!props.start ||
!isRhythmTraining.value ||
remainingMs <= 0
) return;
rhythmRoundCompletionTimer = setTimeout(() => {
rhythmRoundCompletionTimer = null;
if (!props.start || !isRhythmTraining.value) return;
emit("rhythm-round-complete");
scheduleRhythmRoundCompletion(rhythmRoundDurationMs.value);
}, remainingMs);
};
const updateRhythmShootWindow = (isInWindow) => {
const nextIsInWindow = Boolean(isInWindow);
const enteredShootWindow =
@@ -392,8 +364,7 @@ const syncRhythmCountdown = async () => {
const generation = ++rhythmSyncGeneration;
clearRhythmTimers();
if (!isRhythmTraining.value || !props.start) {
clearRhythmRoundCompletionTimer();
if (!isRhythmTraining.value) {
rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = 0;
rhythmRemainingSeconds.value = 0;
@@ -411,7 +382,6 @@ const syncRhythmCountdown = async () => {
!validRhythmShootTime.value ||
!normalizeTimestamp(props.shootWindowStart)
) {
clearRhythmRoundCompletionTimer();
rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = 0;
rhythmRemainingSeconds.value = 0;
@@ -421,7 +391,6 @@ const syncRhythmCountdown = async () => {
rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
scheduleRhythmRoundCompletion(rhythmRemainingMs.value);
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
updateRhythmShootWindow(
rhythmRemainingMs.value > 0 &&
@@ -493,7 +462,6 @@ watch(
watch(
() => [
props.start,
props.trainingType,
props.roundTime,
props.shootTime,
@@ -525,7 +493,7 @@ async function onReceiveMessage(msg) {
audioManager.play("比赛开始");
}
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play(getTrainingEndAudioKey(msg.completed), false);
audioManager.play("练习结束", false);
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
if (props.externalShootResultAudio) return;
@@ -582,7 +550,6 @@ onBeforeUnmount(() => {
clearTimer();
rhythmSyncGeneration += 1;
clearRhythmTimers();
clearRhythmRoundCompletionTimer();
});
</script>
@@ -20,8 +20,7 @@ const previewLines = computed(() => {
: [{ text: line }];
const parts = rawParts
.map((part) => ({
text: String(part?.text ?? part ?? ""),
highlight: part?.highlight === true,
text: String(part?.text ?? part ?? "").trim(),
}))
.filter((part) => part.text);
@@ -49,7 +48,6 @@ const previewLines = computed(() => {
<text
v-for="(part, partIndex) in line.parts"
:key="partIndex"
:class="{ 'difficulty-preview__highlight': part.highlight }"
>{{ part.text }}</text>
</view>
</view>
@@ -91,7 +89,7 @@ const previewLines = computed(() => {
}
.difficulty-preview__copy {
width: 100%;
width: 84%;
margin: 0 auto;
display: block;
color: #ffffff;
@@ -104,9 +102,4 @@ const previewLines = computed(() => {
.difficulty-preview__line {
display: block;
}
.difficulty-preview__highlight {
color: #ffd543;
font-weight: 500;
}
</style>
+31 -33
View File
@@ -110,51 +110,50 @@ const createDifficultySummary = (item = {}) => {
const timeLimit = toNumber(item.time_limit);
const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req);
const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows;
const timeParts = timeLimit > 0
? [{ text: `${timeLimit}`, highlight: true }, { text: "秒内," }]
: [{ text: "不限时," }];
const energyReqPercent = toNumber(item.energy_req_percent);
const shootingTimeText =
timeLimit > 0 ? `在${timeLimit}秒内进行射箭` : "不限时进行射箭";
const enduranceTimeText =
timeLimit > 0
? `在${timeLimit}秒内完成${arrows}箭`
: `不限时完成${arrows}箭`;
const summaryMap = {
base: [
{ parts: [
...timeParts,
{ text: "命中" }, { text: `${arrows}`, highlight: true },
{ text: "支" }, { text: `≥${hitReq}`, highlight: true },
{ text: "环的箭" },
] },
shootingTimeText,
`需要有${arrows}箭命中${hitReq}环内`,
],
endurance: [
{ parts: [
...timeParts,
{ text: "共射出" }, { text: `≥${arrows}`, highlight: true },
{ text: "支箭,且总环数" }, { text: `≥${totalReq}`, highlight: true },
{ text: "环" },
] },
enduranceTimeText,
`且累计环数大于${totalReq}环`,
],
precision: [
{ parts: [
...timeParts,
{ text: "命中" }, { text: `${arrows}`, highlight: true },
{ text: "箭高亮区域" },
] },
shootingTimeText,
`需要有${arrows}箭命中高亮区域`,
],
rhythm: [
{ parts: [
{ text: `${arrows}`, highlight: true },
{ text: "个节奏内,随节奏射箭,命中" },
{ text: `${rhythmRequiredArrows}`, highlight: true },
{ text: "箭成绩" }, { text: `≥${hitReq}`, highlight: true },
{ text: "环且踩中节拍区间" },
] },
arrows > 0
? `需要在指定时间节点射出${arrows}射箭`
: "需要在指定时间节点射箭",
hitReq > 0 ? `且每箭命中${hitReq}环内` : "且每箭命中指定区域",
],
stability: [
{
parts: [
...timeParts,
{ text: "每射一箭" },
{ text: `≥${hitReq}`, highlight: true },
{ text: "环可为能量槽充能,能量充满即过关" },
{ text: timeLimit > 0 ? "在" : "" },
{
text: timeLimit > 0 ? `${timeLimit}秒` : "不限时",
highlight: true,
},
{ text: timeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
{ text: `${hitReq}环`, highlight: true },
{ text: "可获得能量" },
],
},
{
parts: [
{ text: "计时结束能量需要大于" },
{ text: `${energyReqPercent}%`, highlight: true },
],
},
],
@@ -629,7 +628,6 @@ const createPracticeQuery = (difficulty) => {
},
rhythm: {
hitReq: toNumber(difficulty.hit_req),
totalReq: toNumber(difficulty.total_req, toNumber(difficulty.arrows)),
mode: toNumber(difficulty.mode),
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
+71 -55
View File
@@ -102,8 +102,8 @@ const rhythmServerTimestamp = ref(0);
const rhythmFallbackWindowStart = ref(0);
const rhythmFallbackTimestamp = ref(0);
const rhythmHasActiveServerAnchor = ref(false);
// 仅由前端每轮倒计时完成事件累加,不根据服务端箭数推算。
const rhythmCompletedRounds = ref(0);
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。
const rhythmTargetArrows = ref(0);
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({
randomBlock: 0,
@@ -440,11 +440,10 @@ const trainingCopy = computed(() => {
return {
inline: true,
details: [
{ text: "命中 " },
{ text: "计时结束前需要有" },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: " 支" },
{ text: `≥${hitReq}`, highlight: true },
{ text: "环的箭" },
{ text: "箭命中" },
{ text: `${hitReq}环`, highlight: true },
],
};
}
@@ -464,11 +463,11 @@ const trainingCopy = computed(() => {
return {
inline: true,
details: [
{ text: "射出≥ " },
{ text: "计时结束前完成" },
{ text: `(${currentArrows}/${targetArrows})`, highlight: true },
{ text: " 支箭,总环数达到≥ " },
{ text: "箭且累计命中" },
{ text: `(${currentRings}/${targetRings})`, highlight: true },
{ text: " 环" },
{ text: "环" },
],
};
}
@@ -487,34 +486,36 @@ const trainingCopy = computed(() => {
return {
inline: true,
details: [
{ text: "命中高亮区域 " },
{ text: "射箭命中高亮区域需完成" },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: " 箭" },
{ text: "箭" },
],
};
}
if (trainingType.value === "rhythm") {
// 总节奏数沿用关卡配置,与选择页保持一致,不使用消息中的箭数覆盖。
const totalArrows = getPositiveInteger(total.value);
// 节奏数由前端倒计时累计,达标箭数与下方金色箭记录使用同一判定。
const targetArrows =
getPositiveInteger(practiceInfo.value.targetArrows) ||
getPositiveInteger(trainingParams.value.totalReq) || totalArrows;
// 展示当前节奏序号,从 1 开始;内部仍累计已完成次数。
const currentRound = Math.min(totalArrows, rhythmCompletedRounds.value + 1);
const targetArrows = getPositiveInteger(rhythmTargetArrows.value);
const arrowsLeft = Math.min(
targetArrows,
Math.max(
0,
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
)
);
const completedArrows = targetArrows - arrowsLeft;
const hitReq = rhythmHitReq.value;
const qualifiedArrows = scores.value.filter((arrow) => arrow?.ok === true).length;
return {
inline: true,
details: [
{ text: `(${currentRound}/${totalArrows})`, highlight: true },
{ text: " 个节奏内,跟随节奏射箭,命中 " },
{ text: `(${qualifiedArrows}/${targetArrows})`, highlight: true },
{ text: " 箭" },
{ text: `≥${hitReq}`, highlight: true },
{ text: "环且踩中节拍区间" },
{ text: "在进度条读取到高亮区间时射箭命中" },
{
text: hitReq > 0 ? `${hitReq}环内` : "指定区域",
highlight: true,
},
{ text: ",需完成" },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: "箭" },
],
};
}
@@ -524,12 +525,26 @@ const trainingCopy = computed(() => {
practiceInfo.value.hitReq,
trainingParams.value.hitReq
);
const stabilityTimeLimit = getPracticeNumber(
practiceInfo.value.timeLimit,
trainingParams.value.timeLimit
);
return {
inline: true,
details: [
{ text: "每射一箭" },
{ text: `≥${hitReq}`, highlight: true },
{ text: "环可为能量槽充能,充满即过关" },
{ text: stabilityTimeLimit > 0 ? "在" : "" },
{
text: stabilityTimeLimit > 0 ? `${stabilityTimeLimit}秒` : "不限时",
highlight: true,
},
{ text: stabilityTimeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
{ text: `${hitReq}环`, highlight: true },
{ text: "可获得能量,计时结束能量需要大于" },
{
text: `${stabilityEnergyReqPercent.value}%`,
highlight: true,
},
],
};
}
@@ -694,14 +709,31 @@ const cacheRhythmTrainingConfig = (message = {}) => {
}
};
const onRhythmRoundComplete = () => {
if (!isShootingStage.value || !start.value || !isRhythmTraining.value || practiceEnded.value) return;
const totalRounds = getPositiveInteger(total.value);
rhythmCompletedRounds.value = Math.min(totalRounds, rhythmCompletedRounds.value + 1);
const cacheRhythmTargetArrows = (message = {}) => {
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
if (messageTrainingType !== "rhythm") return;
const totalArrows = getPositiveInteger(
message.totalArrows ?? message.total_arrows
);
if (totalArrows > 0) {
rhythmTargetArrows.value = totalArrows;
return;
}
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
if (rhythmTargetArrows.value === 0) {
rhythmTargetArrows.value = getPositiveInteger(
message.arrowsLeft ?? message.arrows_left
);
}
};
const syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message);
cacheRhythmTargetArrows(message);
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
@@ -712,22 +744,6 @@ const syncPracticeInfo = (message = {}) => {
return result;
}, {});
const isRhythmSnapshot =
messageTrainingType === "rhythm" &&
(message.type === undefined ||
[
MESSAGETYPESV2.BattleStart,
MESSAGETYPESV2.ShootResult,
MESSAGETYPESV2.BattleEnd,
].includes(message.type));
if (isRhythmSnapshot) {
// 达标箭数的 0 按 protobuf 默认值处理;剩余数缺失不能用于推算节奏进度。
if (!Object.prototype.hasOwnProperty.call(nextInfo, "qualifiedArrows")) {
nextInfo.qualifiedArrows = 0;
}
}
const isStabilityEnergySnapshot =
messageTrainingType === "stability" &&
(Object.prototype.hasOwnProperty.call(message, "currentEnergy") ||
@@ -1406,7 +1422,7 @@ onLoad((options = {}) => {
serverAddr.value = trainingContext.serverAddr || "";
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
const warmupKeys = [startAudioKey, "通关成功", "通关失败", "胜利", "失败"];
const warmupKeys = [startAudioKey, "胜利", "失败"];
if (trainingParams.value.type === "precision") {
warmupKeys.push("Bingo命中目标", "未命中");
} else if (trainingParams.value.type === "rhythm") {
@@ -1436,7 +1452,6 @@ const onReady = async () => {
}
practiceStarting.value = true;
rhythmCompletedRounds.value = 0;
pageStage.value = pageStages.LOADING;
clearHighlightTestTimer();
useHighlightTest.value = false;
@@ -1678,7 +1693,7 @@ async function onRetry() {
rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0;
rhythmHasActiveServerAnchor.value = false;
rhythmCompletedRounds.value = 0;
rhythmTargetArrows.value = 0;
invalidateShotPresentations({ resetVisible: true });
start.value = false;
scores.value = [];
@@ -1701,7 +1716,7 @@ function onAudioEnded(key) {
Array.from(audioWaiters).forEach((waiter) => {
if (waiter.expectedKey === key) waiter.done();
});
if (["比赛结束", "练习结束", "通关成功", "通关失败"].includes(String(key || ""))) {
if (["比赛结束", "练习结束"].includes(String(key || ""))) {
void onOver();
}
}
@@ -1849,7 +1864,6 @@ onBeforeUnmount(() => {
trainingType === 'stability'
"
:onStop="onTimeLimitReached"
@rhythm-round-complete="onRhythmRoundComplete"
/>
<view class="user-row">
<!-- <Avatar :src="user.avatar" :size="35" /> -->
@@ -2125,7 +2139,7 @@ onBeforeUnmount(() => {
font-size: 26rpx;
font-weight: 400;
line-height: 40rpx;
white-space: pre-line;
white-space: normal;
word-break: break-all;
}
.text1{
@@ -2141,7 +2155,9 @@ onBeforeUnmount(() => {
line-height: 36rpx;
}
.text2-yellow{
font-size: 30rpx;
color: #FFD947;
font-weight: 500;
margin: 0 4rpx;
}
</style>
+6 -30
View File
@@ -1,7 +1,5 @@
<script setup>
import { ref, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getSelectedExperienceStore, hasAnyStoreVip } from "@/utils/storeVip";
import Container from "@/components/Container.vue";
import UserHeader from "@/components/UserHeader.vue";
import UserItem from "@/components/UserItem.vue";
@@ -28,40 +26,20 @@ const toFristTryPage = async () => {
}
};
const toCoinPage = () => {
const selected = getSelectedExperienceStore();
uni.navigateTo({
url: selected ? "/pages/coin/index?tab=vip" : "/pages/coin/nearby-stores?tab=vip",
url: "/pages/coin/nearby-stores",
});
};
const showCoinEntry = ref(false);
const storeVipLabel = ref("加载中");
const storeVipFailed = ref(false);
let entryRequest = 0;
const loadCoinEntryVisibility = async () => {
const requestId = ++entryRequest;
showCoinEntry.value = false;
storeVipFailed.value = false;
storeVipLabel.value = "加载中";
try {
const result = await getMyGoldAPI();
if (requestId !== entryRequest) return;
showCoinEntry.value = result?.hasTempBind === true;
if (!showCoinEntry.value) return;
try {
const active = await hasAnyStoreVip();
if (requestId !== entryRequest) return;
storeVipLabel.value = active ? "VIP已开通" : "VIP未开通";
} catch (error) {
if (requestId !== entryRequest) return;
storeVipLabel.value = "加载失败,重试";
storeVipFailed.value = true;
console.error("加载入口门店VIP失败", error);
}
} catch (error) {
console.error("加载金币入口状态失败", error);
}
};
onShow(loadCoinEntryVisibility);
const toBeVipPage = () => {
uni.navigateTo({
url: "/pages/member/be-vip",
@@ -107,6 +85,7 @@ const logout = () => {
updateUser();
};
onMounted(() => {
loadCoinEntryVisibility();
const accountInfo = uni.getAccountInfoSync();
const envVersion = accountInfo.miniProgram.envVersion;
if (envVersion !== "release") showLogout.value = true;
@@ -148,12 +127,10 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
</UserItem>
<UserItem
v-if="showCoinEntry"
title="线下体验中心"
title="我的金币"
:onClick="toCoinPage"
>
<text class="store-vip-label" @click.stop="storeVipFailed ? loadCoinEntryVisibility() : toCoinPage()">{{ storeVipLabel }}</text>
</UserItem>
<!-- <UserItem title="会员" :onClick="toBeVipPage">
/>
<UserItem title="会员" :onClick="toBeVipPage">
<view
v-if="user.sVip === true"
class="member-nickname member-nickname--svip"
@@ -168,7 +145,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
<text class="member-nickname__text">已开通VIP</text>
</view>
<text v-else>未开通</text>
</UserItem> -->
</UserItem>
<UserItem title="等级介绍" :onClick="toGradeIntroPage">
<text :style="{ color: '#4C76FF' }">Lv{{ user.lvl }}</text>
<text
@@ -228,7 +205,6 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''
</template>
<style scoped>
.store-vip-label { padding: 4rpx 12rpx; border: 1rpx solid #999999; border-radius: 20rpx; font-size: 24rpx; }
.container {
background-color: #f5f5f5;
height: calc(100% - 65px);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

-70
View File
@@ -1,70 +0,0 @@
import { getStoreBenefitAPI, getStoreVipListAPI } from "@/apis";
import useStore from "@/store";
// 按账号隔离最近选择,刷新登录令牌后仍可恢复同一门店。
const selectionKey = () => {
const version = uni.getAccountInfoSync().miniProgram.envVersion;
return `${version}_experience_store_${useStore().user.id || "guest"}`;
};
export const getSelectedExperienceStore = () => {
const value = uni.getStorageSync(selectionKey());
return value && /^\d+$/.test(String(value.id)) && Number(value.id) > 0
? { id: String(value.id), name: String(value.name || "") }
: null;
};
export const rememberExperienceStore = (id, name) => {
uni.setStorageSync(selectionKey(), { id: String(id), name });
};
// 入口汇总所有门店;找到有效会员即可结束,未开通须查询完整列表。
export const hasAnyStoreVip = async () => {
const pageSize = 20;
let received = 0;
for (let page = 1; ; page += 1) {
const result = await getStoreVipListAPI({ page, pageSize });
const list = result?.list;
if (!Array.isArray(list) || !Number.isSafeInteger(result.total) || result.total < 0) {
throw new Error("门店会员列表响应格式异常");
}
if (list.some((item) => item?.isVip === true)) return true;
if (list.some((item) => typeof item?.isVip !== "boolean")) {
throw new Error("门店会员状态缺失");
}
received += list.length;
if (received >= result.total) return false;
// 列表尚未读完却返回空页时,不把不完整结果判为未开通。
if (!list.length) throw new Error("门店会员列表分页异常");
}
};
// 只接受非负整数,缺失或异常值不能转换成“已用完”的 0 次。
const normalizeRemaining = (benefit) => {
const remain = benefit?.remain;
return Number.isSafeInteger(remain) && remain >= 0 ? remain : "--";
};
export const normalizeStoreMembership = (store) => {
if (typeof store?.isVip !== "boolean") throw new Error("门店会员状态缺失");
const active = store.isVip === true;
return {
active,
cardImage: `https://static.shelingxingqiu.com/shootmini/static/coin/store-vip-card-${active ? "active" : "inactive"}.png`,
expiresAt: active ? String(store.vipExpireAt || "").slice(0, 10).replace(/-/g, "/") || "--" : "",
// 未开通或已过期统一为 0;有效会员逐项读取后端剩余次数。
remaining: {
trial: active ? normalizeRemaining(store.freeTrial) : 0,
discount: active ? normalizeRemaining(store.discountPurchase) : 0,
drink: active ? normalizeRemaining(store.drink) : 0,
},
};
};
export const loadStoreMembership = async (storeId) => {
const result = await getStoreBenefitAPI(storeId);
if (String(result?.storeId) !== String(storeId)) {
throw new Error("门店权益响应与当前门店不匹配");
}
return { name: result.storeName || "", ...normalizeStoreMembership(result) };
};
+32 -25
View File
@@ -93,42 +93,49 @@ function createWebSocket(token, onMessage) {
"/addons/shoot/otaResult",
];
if (passthroughEvents.includes(event)) {
if ((code == null || Number(code) === 0) && onMessage && data && typeof data === "object") {
if (
(code == null || Number(code) === 0) &&
onMessage &&
data &&
typeof data === "object"
) {
onMessage({ event, data, code, timestamp });
}
return;
}
if (data?.type) {
if (!data || typeof data !== "object") return;
if (data.type) {
if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
}
if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return;
}
const updates = Array.isArray(data?.updates) ? data.updates : [];
if (!updates.length) return;
if (onMessage) onMessage(updates);
const msg = updates[0];
if (msg) {
if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log(
"收到 WebSocket 更新",
getMessageTypeName(msg.constructor),
{ updateCount: data.updates.length }
);
}
if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
uni.setStorageSync("latestLvl", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.DeviceOnline) {
uni.$emit("update-online", true);
} else if (msg.constructor === MESSAGETYPES.DeviceOffline) {
uni.$emit("update-online", false);
} else if (msg.constructor === MESSAGETYPES.DeviceCharging) {
uni.$emit("device-charging");
}
if (!Array.isArray(data.updates)) return;
if (onMessage) onMessage(data.updates);
const firstMessage = data.updates[0];
if (firstMessage && ENABLE_REALTIME_MESSAGE_LOG) {
console.log(
"收到 WebSocket 更新",
getMessageTypeName(firstMessage.constructor),
{ updateCount: data.updates.length }
);
}
data.updates.forEach((msg) => {
if (msg) {
if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
uni.setStorageSync("latestLvl", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.DeviceOnline) {
uni.$emit("update-online", true);
} else if (msg.constructor === MESSAGETYPES.DeviceOffline) {
uni.$emit("update-online", false);
} else if (msg.constructor === MESSAGETYPES.DeviceCharging) {
uni.$emit("device-charging");
}
}
});
});
socketTask.onError((err) => {