update: 对接金币相关
This commit is contained in:
+56
-2
@@ -26,8 +26,9 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
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(
|
const token = uni.getStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
@@ -51,7 +52,7 @@ function request(method, url, data = {}, baseUrl = BASE_URL) {
|
|||||||
}
|
}
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const {code, data, message} = res.data;
|
const {code, data, message} = res.data;
|
||||||
if (code === 0) resolve(data);
|
if (successCodes.includes(code)) resolve(data);
|
||||||
else if (message) {
|
else if (message) {
|
||||||
const error = {code, data, message};
|
const error = {code, data, message};
|
||||||
if (message.indexOf("登录身份已失效") !== -1) {
|
if (message.indexOf("登录身份已失效") !== -1) {
|
||||||
@@ -698,3 +699,56 @@ export const getMyTenRingRank = (seasonId) => {
|
|||||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||||
return request("GET", "/index/myTenRingRank", data);
|
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"} = {}) => {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
"/gin/api/v1/gift/list",
|
||||||
|
{page, page_size: pageSize, sort},
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
|||||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||||
import { capsuleHeight, debounce } from "@/util";
|
import { capsuleHeight, debounce } from "@/util";
|
||||||
import { returnToBattle } from "@/utils/matchReturn";
|
import { returnToBattle } from "@/utils/matchReturn";
|
||||||
|
const emit = defineEmits(["scrolltolower"]);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -159,6 +160,8 @@ const goCalibration = async () => {
|
|||||||
:enhanced="true"
|
:enhanced="true"
|
||||||
:bounces="false"
|
:bounces="false"
|
||||||
:show-scrollbar="false"
|
:show-scrollbar="false"
|
||||||
|
:lower-threshold="120"
|
||||||
|
@scrolltolower="emit('scrolltolower')"
|
||||||
:style="{
|
:style="{
|
||||||
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
||||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
defineProps({
|
const DEFAULT_PRODUCT_IMAGE = "/static/coin/product-hero-item.png";
|
||||||
backgrounds: {
|
const props = defineProps({
|
||||||
|
images: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
productImage: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const current = ref(0);
|
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) => {
|
const onChange = (event) => {
|
||||||
current.value = event.detail.current;
|
current.value = event.detail.current;
|
||||||
@@ -22,16 +25,20 @@ const onChange = (event) => {
|
|||||||
<template>
|
<template>
|
||||||
<view class="hero-swiper">
|
<view class="hero-swiper">
|
||||||
<swiper class="hero-swiper__body" :duration="260" @change="onChange">
|
<swiper class="hero-swiper__body" :duration="260" @change="onChange">
|
||||||
<swiper-item v-for="(background, index) in backgrounds" :key="index">
|
<swiper-item v-for="(image, index) in slideImages" :key="index">
|
||||||
<view class="hero-swiper__item">
|
<view class="hero-swiper__item">
|
||||||
<image class="hero-swiper__background" :src="background" mode="scaleToFill" />
|
<image
|
||||||
<image class="hero-swiper__product" :src="productImage" mode="aspectFit" />
|
class="hero-swiper__background"
|
||||||
|
src="/static/coin/product-hero-bg.png"
|
||||||
|
mode="scaleToFill"
|
||||||
|
/>
|
||||||
|
<image class="hero-swiper__product" :src="image" mode="aspectFit" />
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
<view class="hero-swiper__dots">
|
<view class="hero-swiper__dots">
|
||||||
<view
|
<view
|
||||||
v-for="(_, index) in backgrounds"
|
v-for="(_, index) in slideImages"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="hero-swiper__dot"
|
class="hero-swiper__dot"
|
||||||
:class="{ 'hero-swiper__dot--active': current === index }"
|
:class="{ 'hero-swiper__dot--active': current === index }"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_IMAGE = "/static/coin/product-hero-bg.png";
|
const DEFAULT_PRODUCT_IMAGE = "/static/coin/product-card-item.png";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
product: {
|
product: {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
export const coinSummary = {
|
|
||||||
storeName: "广州大学城北旗舰体验店",
|
|
||||||
cumulative: 9999,
|
|
||||||
available: 8800,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const quickMenus = [
|
export const quickMenus = [
|
||||||
{
|
{
|
||||||
key: "rules",
|
key: "rules",
|
||||||
@@ -27,15 +21,6 @@ export const quickMenus = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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 = [
|
export const coinRules = [
|
||||||
"金币获取规则:登录个人账号后,使用射灵智能弓体验个人训练、好友约战、排位赛三种模式,每射出一箭并判为有效,即可根据该箭的实际环数,获得等额金币。如:用户在个人训练中,射出一箭并获得9环,即获得9金币。",
|
"金币获取规则:登录个人账号后,使用射灵智能弓体验个人训练、好友约战、排位赛三种模式,每射出一箭并判为有效,即可根据该箭的实际环数,获得等额金币。如:用户在个人训练中,射出一箭并获得9环,即获得9金币。",
|
||||||
"累计金币:累计金币为用户历史获得的所有金币,不会过期、不受兑换影响。",
|
"累计金币:累计金币为用户历史获得的所有金币,不会过期、不受兑换影响。",
|
||||||
@@ -43,72 +28,3 @@ export const coinRules = [
|
|||||||
"射灵星球官方可能会不定期举办各类活动,有机会额外获得更多机会,具体以实际公告为准。",
|
"射灵星球官方可能会不定期举办各类活动,有机会额外获得更多机会,具体以实际公告为准。",
|
||||||
"金币以每个门店/俱乐部为单位独立计算与兑换核销,暂不支持跨门店、跨俱乐部计算,具体以各门店实际公告为准。",
|
"金币以每个门店/俱乐部为单位独立计算与兑换核销,暂不支持跨门店、跨俱乐部计算,具体以各门店实际公告为准。",
|
||||||
];
|
];
|
||||||
|
|
||||||
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 日内凭完整包装联系客服核实处理,提供维修 / 更换方案。品牌赠品官方保修请参照品牌厂商政策。",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,28 +1,102 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||||
import { earningRecords } from "./data";
|
import { getGoldLogAPI } from "@/apis";
|
||||||
|
|
||||||
const showEmpty = ref(false);
|
const PAGE_SIZE = 20;
|
||||||
|
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);
|
||||||
|
|
||||||
onLoad((options = {}) => {
|
const formatRecord = (item = {}) => {
|
||||||
// 预留空态入口,接口接入后由真实列表长度控制。
|
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||||
showEmpty.value = options.state === "empty";
|
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,
|
||||||
});
|
});
|
||||||
|
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(() => loadRecords({ reset: true }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="6" :isHome="true">
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||||
<template #header>
|
<template #header>
|
||||||
<CoinHeader title="金币获取明细" />
|
<CoinHeader title="金币获取明细" />
|
||||||
</template>
|
</template>
|
||||||
<view class="records-page">
|
<view class="records-page">
|
||||||
<CoinEmptyState v-if="showEmpty" />
|
<CoinEmptyState v-if="showEmpty" />
|
||||||
<CoinRecordList v-else :records="earningRecords" />
|
<CoinRecordList v-else :records="earningRecords" />
|
||||||
|
<view
|
||||||
|
v-if="loading || (noMore && earningRecords.length)"
|
||||||
|
class="records-page__status"
|
||||||
|
>
|
||||||
|
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,4 +106,12 @@ onLoad((options = {}) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,102 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import CoinRecordList from "./components/CoinRecordList.vue";
|
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||||
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||||
import { exchangeRecords } from "./data";
|
import { getGoldLogAPI } from "@/apis";
|
||||||
|
|
||||||
const showEmpty = ref(false);
|
const PAGE_SIZE = 20;
|
||||||
|
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);
|
||||||
|
|
||||||
onLoad((options = {}) => {
|
const formatRecord = (item = {}) => {
|
||||||
// 预留空态入口,接口接入后由真实列表长度控制。
|
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||||
showEmpty.value = options.state === "empty";
|
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,
|
||||||
});
|
});
|
||||||
|
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(() => loadRecords({ reset: true }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="6" :isHome="true">
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||||
<template #header>
|
<template #header>
|
||||||
<CoinHeader title="金币兑换记录" />
|
<CoinHeader title="金币兑换记录" />
|
||||||
</template>
|
</template>
|
||||||
<view class="records-page">
|
<view class="records-page">
|
||||||
<CoinEmptyState v-if="showEmpty" text="暂无金币兑换记录。" />
|
<CoinEmptyState v-if="showEmpty" text="暂无金币兑换记录。" />
|
||||||
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
|
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
|
||||||
|
<view
|
||||||
|
v-if="loading || (noMore && exchangeRecords.length)"
|
||||||
|
class="records-page__status"
|
||||||
|
>
|
||||||
|
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,4 +106,12 @@ onLoad((options = {}) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,86 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
||||||
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
||||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||||
import RewardProductCard from "./components/RewardProductCard.vue";
|
import RewardProductCard from "./components/RewardProductCard.vue";
|
||||||
import { coinSummary, quickMenus, rewardProducts } from "./data";
|
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
||||||
|
import { quickMenus } from "./data";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const coinSummary = ref({
|
||||||
|
cumulative: 0,
|
||||||
|
available: 0,
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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 = {
|
const menuRoutes = {
|
||||||
rules: "/pages/coin/rules",
|
rules: "/pages/coin/rules",
|
||||||
@@ -26,17 +101,22 @@ const onMenuSelect = (menu) => {
|
|||||||
uni.navigateTo({ url });
|
uni.navigateTo({ url });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toProductDetail = () => {
|
const toProductDetail = (product) => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/coin/product-detail",
|
url: `/pages/coin/product-detail?id=${product.id}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
loadCoinSummary();
|
||||||
|
loadProducts({ reset: true });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="6" :isHome="true">
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
|
||||||
<template #header>
|
<template #header>
|
||||||
<CoinHeader title="我的金币" :subtitle="coinSummary.storeName" />
|
<CoinHeader title="我的金币" />
|
||||||
</template>
|
</template>
|
||||||
<view class="coin-page">
|
<view class="coin-page">
|
||||||
<CoinBalancePanel
|
<CoinBalancePanel
|
||||||
@@ -51,7 +131,7 @@ const toProductDetail = () => {
|
|||||||
v-for="product in rewardProducts"
|
v-for="product in rewardProducts"
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
class="reward-grid__item"
|
class="reward-grid__item"
|
||||||
@click="toProductDetail"
|
@click="toProductDetail(product)"
|
||||||
>
|
>
|
||||||
<RewardProductCard :product="product" />
|
<RewardProductCard :product="product" />
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -5,22 +5,106 @@ import Container from "@/components/Container.vue";
|
|||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||||
import StoreCard from "./components/StoreCard.vue";
|
import StoreCard from "./components/StoreCard.vue";
|
||||||
import { nearbyStore } from "./data";
|
import { getNearbyStoresAPI } from "@/apis";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const DEFAULT_STORE_IMAGE = "/static/coin/store-photo.png";
|
||||||
const showPermissionState = ref(false);
|
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);
|
||||||
|
|
||||||
onLoad((options = {}) => {
|
const getCurrentLocation = () =>
|
||||||
// 预留未授权态,接口接入后替换为微信定位授权结果。
|
new Promise((resolve, reject) => {
|
||||||
showPermissionState.value = options.state === "empty";
|
uni.getLocation({
|
||||||
|
type: "gcj02",
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const authorizeLocation = () => {
|
const mapStore = (item = {}) => ({
|
||||||
showPermissionState.value = false;
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad(locateAndLoadStores);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="6" :isHome="true">
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadStores">
|
||||||
<template #header>
|
<template #header>
|
||||||
<CoinHeader title="附近门店" />
|
<CoinHeader title="附近门店" />
|
||||||
</template>
|
</template>
|
||||||
@@ -30,15 +114,11 @@ const authorizeLocation = () => {
|
|||||||
@authorize="authorizeLocation"
|
@authorize="authorizeLocation"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<StoreCard :store="nearbyStore" />
|
<StoreCard v-for="store in stores" :key="store.id" :store="store" />
|
||||||
<view class="store-placeholder">
|
<text v-if="loaded && !stores.length" class="stores-page__more">
|
||||||
<image
|
附近暂无门店~
|
||||||
class="store-placeholder__background"
|
</text>
|
||||||
src="/static/coin/store-placeholder-bg.png"
|
<text v-else-if="noMore" class="stores-page__more">没有更多门店了~</text>
|
||||||
mode="scaleToFill"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text class="stores-page__more">更多门店敬请期待~</text>
|
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -52,23 +132,6 @@ const authorizeLocation = () => {
|
|||||||
box-sizing: border-box;
|
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 {
|
.stores-page__more {
|
||||||
display: block;
|
display: block;
|
||||||
width: 650rpx;
|
width: 650rpx;
|
||||||
|
|||||||
@@ -1,10 +1,60 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import CoinHeader from "./components/CoinHeader.vue";
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
import ProductHeroSwiper from "./components/ProductHeroSwiper.vue";
|
import ProductHeroSwiper from "./components/ProductHeroSwiper.vue";
|
||||||
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||||
import { productDetail } from "./data";
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -14,14 +64,13 @@ import { productDetail } from "./data";
|
|||||||
</template>
|
</template>
|
||||||
<view class="product-detail">
|
<view class="product-detail">
|
||||||
<ProductHeroSwiper
|
<ProductHeroSwiper
|
||||||
:backgrounds="productDetail.backgrounds"
|
:images="productDetail.images"
|
||||||
:productImage="productDetail.image"
|
|
||||||
/>
|
/>
|
||||||
<view class="product-detail__summary">
|
<view class="product-detail__summary">
|
||||||
<text class="product-detail__name">{{ productDetail.name }}</text>
|
<text class="product-detail__name">{{ productDetail.name }}</text>
|
||||||
<view class="product-detail__balance">
|
<view class="product-detail__balance">
|
||||||
<text>累计金币:</text>
|
<text>金币:</text>
|
||||||
<text class="product-detail__amount">{{ productDetail.cumulative }}</text>
|
<text class="product-detail__amount">{{ productDetail.coinPrice }}</text>
|
||||||
<text>(剩余{{ productDetail.stock }}个)</text>
|
<text>(剩余{{ productDetail.stock }}个)</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
Reference in New Issue
Block a user