2 Commits
Author SHA1 Message Date
zhangyi 63f3cada0f update:暂存金币相关 2026-08-19 10:14:17 +08:00
zhangyi d60c32506f update:优化机构扫码页 2026-08-17 14:05:52 +08:00
39 changed files with 3546 additions and 471 deletions
+3 -2
View File
@@ -131,8 +131,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"
@@ -159,7 +160,7 @@ const goCalibration = async () => {
:bounces="false"
:show-scrollbar="false"
: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'
})`,
}"
+3
View File
@@ -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>
+23
View File
@@ -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,34 @@
<script setup>
defineProps({
text: {
type: String,
default: "暂无金币获取记录。",
},
});
</script>
<template>
<view class="empty-state">
<image src="/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>
+86
View File
@@ -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,51 @@
<script setup>
const emit = defineEmits(["authorize"]);
</script>
<template>
<view class="location-state">
<image src="/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,53 @@
<script setup>
defineProps({
variant: {
type: String,
default: "gold",
},
});
</script>
<template>
<view class="exchange-notice" :class="`exchange-notice--${variant}`">
<image src="/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,82 @@
<script setup>
import { ref } from "vue";
defineProps({
backgrounds: {
type: Array,
default: () => [],
},
productImage: {
type: String,
default: "",
},
});
const current = ref(0);
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="(background, index) in backgrounds" :key="index">
<view class="hero-swiper__item">
<image class="hero-swiper__background" :src="background" mode="scaleToFill" />
<image class="hero-swiper__product" :src="productImage" mode="aspectFit" />
</view>
</swiper-item>
</swiper>
<view class="hero-swiper__dots">
<view
v-for="(_, index) in backgrounds"
: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,96 @@
<script setup>
import { computed } from "vue";
const DEFAULT_PRODUCT_IMAGE = "/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>
+63
View File
@@ -0,0 +1,63 @@
<script setup>
defineProps({
store: {
type: Object,
required: true,
},
});
</script>
<template>
<view class="store-card">
<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>
+114
View File
@@ -0,0 +1,114 @@
export const coinSummary = {
storeName: "广州大学城北旗舰体验店",
cumulative: 9999,
available: 8800,
};
export const quickMenus = [
{
key: "rules",
label: "金币规则",
icon: "/static/coin/icon-rules.png",
},
{
key: "earningRecords",
label: "获取明细",
icon: "/static/coin/icon-earning-records.png",
},
{
key: "exchangeRecords",
label: "兑换记录",
icon: "/static/coin/icon-exchange-records.png",
},
{
key: "nearbyStores",
label: "附近门店",
icon: "/static/coin/icon-nearby-store.png",
},
];
export const rewardProducts = Array.from({ length: 6 }, (_, index) => ({
id: index + 1,
name: "射灵公仔",
cost: 3000,
stock: 100,
background: "/static/coin/product-card-bg.png",
image: "/static/coin/product-card-item.png",
}));
export const coinRules = [
"金币获取规则:登录个人账号后,使用射灵智能弓体验个人训练、好友约战、排位赛三种模式,每射出一箭并判为有效,即可根据该箭的实际环数,获得等额金币。如:用户在个人训练中,射出一箭并获得9环,即获得9金币。",
"累计金币:累计金币为用户历史获得的所有金币,不会过期、不受兑换影响。",
"可兑换金币:可兑换金币为用户可用于兑换奖品的金币,用户使用金币兑换相应礼品后,将扣除相应金币。同时,可兑换金币有1年有效期,自金币获得之日起开始计算。",
"射灵星球官方可能会不定期举办各类活动,有机会额外获得更多机会,具体以实际公告为准。",
"金币以每个门店/俱乐部为单位独立计算与兑换核销,暂不支持跨门店、跨俱乐部计算,具体以各门店实际公告为准。",
];
export const earningRecords = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
date: "2026-07-12",
time: "13:23:43",
type: "个人训练-射箭8环",
amount: "+8",
}));
export const exchangeRecords = [
{
id: 1,
content: "苹果Airpods Pro耳机一对",
type: "个人兑换",
date: "2026-07-12",
time: "13:23:43",
amount: "-2000",
},
{
id: 2,
content: "金币过期",
type: "金币过期",
date: "2026-07-12",
time: "13:23:43",
amount: "-1500",
},
{
id: 3,
content: "苹果Airpods Pro耳机一对",
type: "队伍兑换",
date: "2026-07-12",
time: "13:23:43",
amount: "-1500",
},
{
id: 4,
content: "人工扣除",
type: "人工扣除",
date: "2026-07-12",
time: "13:23:43",
amount: "-1500",
},
];
export const nearbyStore = {
name: "射灵星球智能弓体验馆-广州大学城北店",
address: "广州市番禺区大学城青蓝街1栋b4(广州大学城北)",
phone: "030-23392934",
hours: "10:00-20:00",
image: "/static/coin/store-photo.png",
};
export const productDetail = {
name: "射灵星球定制毛巾两条",
cumulative: 9999,
stock: 10,
backgrounds: [
"/static/coin/product-hero-bg.png",
"/static/coin/product-hero-bg.png",
"/static/coin/product-hero-bg.png",
"/static/coin/product-hero-bg.png",
],
image: "/static/coin/product-hero-item.png",
descriptions: [
"本礼品具体型号为 苹果Airpods Pro二代无线降噪耳机(国行版)。颜色随机,不支持指定。",
"兑换请当场进行开箱验货,一经兑换,不支持无理由退换。",
"本次活动礼品为促销赠品,保证全新完好,具备正常使用功能。礼品非售卖商品,不支持单独退换;若收到礼品存在质量瑕疵,请收货7 日内凭完整包装联系客服核实处理,提供维修 / 更换方案。品牌赠品官方保修请参照品牌厂商政策。",
],
};
+35
View File
@@ -0,0 +1,35 @@
<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 CoinRecordList from "./components/CoinRecordList.vue";
import CoinEmptyState from "./components/CoinEmptyState.vue";
import { earningRecords } from "./data";
const showEmpty = ref(false);
onLoad((options = {}) => {
// 预留空态入口,接口接入后由真实列表长度控制。
showEmpty.value = options.state === "empty";
});
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="金币获取明细" />
</template>
<view class="records-page">
<CoinEmptyState v-if="showEmpty" />
<CoinRecordList v-else :records="earningRecords" />
</view>
</Container>
</template>
<style scoped>
.records-page {
width: 100%;
min-height: 100%;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<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 CoinRecordList from "./components/CoinRecordList.vue";
import CoinEmptyState from "./components/CoinEmptyState.vue";
import { exchangeRecords } from "./data";
const showEmpty = ref(false);
onLoad((options = {}) => {
// 预留空态入口,接口接入后由真实列表长度控制。
showEmpty.value = options.state === "empty";
});
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="金币兑换记录" />
</template>
<view class="records-page">
<CoinEmptyState v-if="showEmpty" text="暂无金币兑换记录。" />
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
</view>
</Container>
</template>
<style scoped>
.records-page {
width: 100%;
min-height: 100%;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup>
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 { coinSummary, quickMenus, rewardProducts } from "./data";
const menuRoutes = {
rules: "/pages/coin/rules",
earningRecords: "/pages/coin/earning-records",
exchangeRecords: "/pages/coin/exchange-records",
nearbyStores: "/pages/coin/nearby-stores",
};
const onMenuSelect = (menu) => {
const url = menuRoutes[menu.key];
if (!url) {
uni.showToast({
title: "功能开发中",
icon: "none",
});
return;
}
uni.navigateTo({ url });
};
const toProductDetail = () => {
uni.navigateTo({
url: "/pages/coin/product-detail",
});
};
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="我的金币" :subtitle="coinSummary.storeName" />
</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"
>
<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>
+81
View File
@@ -0,0 +1,81 @@
<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 { nearbyStore } from "./data";
const showPermissionState = ref(false);
onLoad((options = {}) => {
// 预留未授权态,接口接入后替换为微信定位授权结果。
showPermissionState.value = options.state === "empty";
});
const authorizeLocation = () => {
showPermissionState.value = false;
};
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="附近门店" />
</template>
<view class="stores-page">
<LocationPermissionState
v-if="showPermissionState"
@authorize="authorizeLocation"
/>
<template v-else>
<StoreCard :store="nearbyStore" />
<view class="store-placeholder">
<image
class="store-placeholder__background"
src="/static/coin/store-placeholder-bg.png"
mode="scaleToFill"
/>
</view>
<text 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;
}
.store-placeholder {
position: relative;
width: 650rpx;
height: 324rpx;
margin: 44rpx auto 0;
overflow: hidden;
border-radius: 12rpx;
}
.store-placeholder__background {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.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>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
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 { productDetail } from "./data";
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="商品详情" />
</template>
<view class="product-detail">
<ProductHeroSwiper
:backgrounds="productDetail.backgrounds"
:productImage="productDetail.image"
/>
<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.cumulative }}</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>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import Container from "@/components/Container.vue";
import CoinHeader from "./components/CoinHeader.vue";
import { coinRules } from "./data";
</script>
<template>
<Container :bgType="6" :isHome="true">
<template #header>
<CoinHeader title="金币规则" />
</template>
<view class="rules-page">
<view
v-for="(rule, index) in coinRules"
:key="index"
class="rules-page__item"
>
<text>{{ index + 1 }}、{{ rule }}</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;
}
</style>
+46 -117
View File
@@ -1,5 +1,5 @@
<script setup>
import {onMounted, onUnmounted, ref, watch} from "vue";
import {onMounted, onUnmounted, ref} from "vue";
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import AppFooter from "@/components/AppFooter.vue";
@@ -52,41 +52,20 @@ const otaInfo = ref({
forceUpdate: false,
});
const isStartingOta = ref(false);
let isCheckingOta = false;
let otaCheckQueuedForOnline = false;
let otaProgressTimer = null;
let otaStatusTimer = null;
let otaTimeoutTimer = null;
let otaResultTimer = null;
let otaStatusPollCount = 0;
let otaUpdateRunId = 0;
// 首页 OTA 轮询采用请求次数和总时长双重兜底,避免更新中状态长期卡住。
const OTA_TASK_STATUS_POLL_INTERVAL = 2000;
const OTA_TASK_STATUS_MAX_POLL_COUNT = 15;
const OTA_TASK_STATUS_TIMEOUT = 30000;
// 清理首页 OTA 更新定时器,避免弹窗关闭或页面卸载后继续轮询。
const clearOtaUpdateTimers = () => {
clearInterval(otaProgressTimer);
clearTimeout(otaStatusTimer);
clearTimeout(otaTimeoutTimer);
clearTimeout(otaResultTimer);
otaProgressTimer = null;
otaStatusTimer = null;
otaTimeoutTimer = null;
otaResultTimer = null;
otaStatusPollCount = 0;
};
// 使当前 OTA 运行失效,确保旧请求返回后不会继续轮询或覆盖新状态。
const invalidateOtaUpdateRun = () => {
otaUpdateRunId += 1;
clearOtaUpdateTimers();
};
const isOtaUpdateRunActive = (runId) =>
runId === otaUpdateRunId && otaState.value === "update_progress";
// 启动首页 OTA 本地进度动画,最终成功失败以后端任务状态为准。
const startOtaProgressAnimation = () => {
clearInterval(otaProgressTimer);
@@ -112,51 +91,21 @@ const applyOtaVersionInfo = (versionInfo) => {
// 检查当前设备盒子是否存在可升级版本。
const checkOtaUpdate = async () => {
if (isCheckingOta || otaVisible.value) return;
isCheckingOta = true;
let versionInfo;
try {
let deviceStatus;
try {
deviceStatus = await getDeviceBatteryAPI();
} catch (err) {
return;
}
if (deviceStatus?.online !== true || otaVisible.value) return;
let versionInfo;
try {
versionInfo = await getHardwareBoxVersionAPI();
} catch (err) {
return;
}
if (!versionInfo?.needUpdate || otaVisible.value) return;
applyOtaVersionInfo(versionInfo);
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
const now = Date.now();
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
otaState.value = "new_version";
otaVisible.value = true;
} finally {
isCheckingOta = false;
const shouldRecheckForOnline = otaCheckQueuedForOnline;
otaCheckQueuedForOnline = false;
if (shouldRecheckForOnline && !otaVisible.value) {
void checkOtaUpdate();
}
}
};
// 设备由 WS 通知上线时补查 OTA;已显示任何 OTA 弹窗时保留当前流程和结果。
watch(online, (nextOnline, previousOnline) => {
if (previousOnline !== false || nextOnline !== true || otaVisible.value) return;
if (isCheckingOta) {
otaCheckQueuedForOnline = true;
versionInfo = await getHardwareBoxVersionAPI();
} catch (err) {
return;
}
void checkOtaUpdate();
});
if (!versionInfo?.needUpdate) return;
applyOtaVersionInfo(versionInfo);
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
const now = Date.now();
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
otaState.value = "new_version";
otaVisible.value = true;
};
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
const getOtaWifiUrl = () => {
@@ -176,78 +125,61 @@ const handleOtaDismiss = () => {
};
// 将首页 OTA 直连更新流程标记为失败。
const failHomeOtaUpdate = (runId) => {
if (!isOtaUpdateRunActive(runId)) return;
invalidateOtaUpdateRun();
const failHomeOtaUpdate = () => {
clearOtaUpdateTimers();
isStartingOta.value = false;
otaState.value = "update_failure";
otaVisible.value = true;
};
// 将首页 OTA 直连更新流程标记为成功。
const completeHomeOtaUpdate = (runId) => {
if (!isOtaUpdateRunActive(runId)) return;
invalidateOtaUpdateRun();
const completeHomeOtaUpdate = () => {
clearOtaUpdateTimers();
isStartingOta.value = false;
otaProgress.value = 100;
const completedRunId = otaUpdateRunId;
otaResultTimer = setTimeout(() => {
otaResultTimer = null;
if (completedRunId !== otaUpdateRunId) return;
setTimeout(() => {
otaState.value = "update_success";
otaVisible.value = true;
}, 300);
};
// 轮询首页直接发起的 OTA 更新任务状态。
const pollHomeOtaTaskStatus = async (taskId, runId) => {
if (!isOtaUpdateRunActive(runId)) return;
otaStatusPollCount += 1;
try {
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
if (!isOtaUpdateRunActive(runId)) return;
const status = Number(taskStatus?.status);
if (status === 2) {
completeHomeOtaUpdate(runId);
return;
}
if (status === 3) {
failHomeOtaUpdate(runId);
return;
}
if (status === 0 || status === 1) {
if (otaStatusPollCount >= OTA_TASK_STATUS_MAX_POLL_COUNT) {
failHomeOtaUpdate(runId);
const pollHomeOtaTaskStatus = (taskId) => {
clearTimeout(otaStatusTimer);
otaStatusTimer = setTimeout(async () => {
try {
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
const status = Number(taskStatus?.status);
if (status === 2) {
completeHomeOtaUpdate();
return;
}
otaStatusTimer = setTimeout(() => {
otaStatusTimer = null;
if (!isOtaUpdateRunActive(runId)) return;
void pollHomeOtaTaskStatus(taskId, runId);
}, OTA_TASK_STATUS_POLL_INTERVAL);
return;
if (status === 3) {
failHomeOtaUpdate();
return;
}
if (status === 0 || status === 1) {
pollHomeOtaTaskStatus(taskId);
return;
}
failHomeOtaUpdate();
} catch (err) {
failHomeOtaUpdate();
}
failHomeOtaUpdate(runId);
} catch (err) {
if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(runId);
}
}, 3000);
};
// 设备盒子已连 WiFi 时,从首页直接传空 WiFi 信息发起 OTA 更新。
const startHomeOtaUpdate = async () => {
invalidateOtaUpdateRun();
const runId = otaUpdateRunId;
otaState.value = "update_progress";
otaVisible.value = true;
otaProgress.value = 0;
startOtaProgressAnimation();
otaTimeoutTimer = setTimeout(() => {
if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(runId);
}, OTA_TASK_STATUS_TIMEOUT);
if (otaState.value === "update_progress") {
failHomeOtaUpdate();
}
}, 5 * 60 * 1000);
try {
const updateResult = await sendHardwareBoxUpdateAPI({
@@ -256,15 +188,13 @@ const startHomeOtaUpdate = async () => {
wifiPassword: "",
resourceUrl: otaInfo.value.resourceUrl,
});
if (!isOtaUpdateRunActive(runId)) return;
if (!updateResult?.taskId) {
failHomeOtaUpdate(runId);
failHomeOtaUpdate();
return;
}
void pollHomeOtaTaskStatus(updateResult.taskId, runId);
pollHomeOtaTaskStatus(updateResult.taskId);
} catch (err) {
if (!isOtaUpdateRunActive(runId)) return;
failHomeOtaUpdate(runId);
failHomeOtaUpdate();
}
};
@@ -310,7 +240,6 @@ const handleOtaDone = () => {
// 处理 OTA 更新失败后的重试按钮,重新走立即更新判断流程。
const handleOtaRetry = () => {
invalidateOtaUpdateRun();
handleOtaUpdate();
};
@@ -422,7 +351,7 @@ onMounted(async () => {
});
onUnmounted(() => {
invalidateOtaUpdateRun();
clearOtaUpdateTimers();
});
onShareAppMessage(() => {
+145 -33
View File
@@ -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") {
@@ -83,45 +110,69 @@ 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>
<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 +190,93 @@ 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;
}
.state-title {
font-size: 36rpx;
font-size: 38rpx;
font-weight: 600;
}
+6
View File
@@ -24,6 +24,11 @@ const toFristTryPage = async () => {
});
}
};
const toCoinPage = () => {
uni.navigateTo({
url: "/pages/coin/index",
});
};
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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+2052 -319
View File
File diff suppressed because it is too large Load Diff