fix: 修复登录失败被当成功、列表假分页与首页裁切等隐患

1. 登录/验证码改判业务码 data.code(外层 errcode 恒为0),失败不再写入 phone 造成假登录态,并透出后端真实原因;倒计时改为发送成功后才启动;token 按 accessToken 兜底读取,不再写入 undefined

2. 权益包三 Tab 由 pageSize=1000 的假分页改为真实分页 + 页面级触底加载,各 Tab 独立游标与请求序号,合并去重兜底;中奖名单页码改为按期维护,修复切期后重复追加

3. 首页去掉整页 overflow:hidden,小屏不再裁切活动指南卡片;接口表去重并清理 7 个废弃端点;基址/客服电话集中到 common/config.js;删除权益详情不可达的假成功分支
This commit is contained in:
jacobxu666
2026-09-14 23:38:13 +08:00
parent 4e72e81d47
commit 745f4bf5f1
8 changed files with 279 additions and 255 deletions
+12
View File
@@ -0,0 +1,12 @@
// xuchao: 全站集中配置项 —— 后端基址/客服电话等只在这里维护一处
const config = {
// 后端 API 基址。当前指向 UAT 测试环境(见 AGENTS.md 部署信息),切生产改这一行即可
baseUrl: 'https://dscp-uat.hn12301.net',
// 对外统一客服电话,与《用户协议》《隐私协议》公示号码保持一致
servicePhone: '0731-88912301',
// xuchao: open-api/pingXiang/login 强制校验固定 password 字段(后端为 H5 开的旁路),
// 前端去掉即登录失败,故集中存放并在此说明;待后端改造 H5 免密登录后删除。
loginFixedPassword: '1232321'
}
export default config
+55 -69
View File
@@ -1,87 +1,73 @@
// 如果没有通过拦截器配置域名的话,可以在这里写上完整的URL(加上域名部分) // xuchao: 怀化人社 H5 接口清单,与 docs/hhrs-api-doc.md 一一对应
let LoginUrl = 'open-api/pingXiang/login'; // 基址由 common/http.interceptor.js 统一注入(common/config.js.baseUrl)
let SendVerifyCodeUrl = 'open-api/pingXiang/sendLoginVerifyCode'; // 登录 / 云闪付入口
let VenueListUrl = 'mp-api/hhrs/venue/list'; const LoginUrl = 'open-api/pingXiang/login'
let VenueDetailUrl = 'mp-api/hhrs/venue/detail'; const SendVerifyCodeUrl = 'open-api/pingXiang/sendLoginVerifyCode'
let MerchantListUrl = 'mp-api/hhrs/merchant/list'; const TokenDecodeUrl = 'open-api/hhService/tokenDecode'
let MerchantDetailUrl = 'mp-api/hhrs/merchant/detail'; // 门店(文旅场馆 typeId=1 / 商户折扣 typeId=2)
let RightsInfoUrl = 'mp-api/hhrs/rights/info'; const StoreListUrl = 'mp-api/hhrsStore/list'
let RightsDetailUrl = 'mp-api/hhrsRights/detail'; const StoreDetailUrl = 'mp-api/hhrsStore/detail'
let RightsAuthUrl = 'mp-api/hhrs/rights/auth'; // 权益包
let EligibilityUrl = 'mp-api/hhrsRights/eligibility'; const RightsListUrl = 'mp-api/hhrsRights/list'
let RightsBookUrl = 'mp-api/hhrs/rights/book'; const RightsDetailUrl = 'mp-api/hhrsRights/detail'
let InitFaceVerifyUrl = 'open-api/aliyun/face/initFaceVerify'; const EligibilityUrl = 'mp-api/hhrsRights/eligibility'
let DescribeFaceVerifyUrl = 'open-api/aliyun/face/describeFaceVerify'; const AppointmentListUrl = 'mp-api/hhrsRights/appointment/list'
let RightsListUrl = 'mp-api/hhrsRights/list'; const CheckListUrl = 'mp-api/hhrsRights/check/list'
let AppointmentListUrl = 'mp-api/hhrsRights/appointment/list'; // 实名人脸认证(阿里云)
let CheckListUrl = 'mp-api/hhrsRights/check/list'; const InitFaceVerifyUrl = 'open-api/aliyun/face/initFaceVerify'
let StoreListUrl = 'mp-api/hhrsStore/list'; const DescribeFaceVerifyUrl = 'open-api/aliyun/face/describeFaceVerify'
let StoreDetailUrl = 'mp-api/hhrsStore/detail'; // 中奖公示
let WinnerDatesUrl = 'mp-api/hhrsWinner/dates'; const WinnerDatesUrl = 'mp-api/hhrsWinner/dates'
let WinnerListUrl = 'mp-api/hhrsWinner/list'; const WinnerListUrl = 'mp-api/hhrsWinner/list'
let AppointUrl = 'zhy-api/cardBag/appoint'; // 预约下单(智慧游卡包)
let TokenDecodeUrl = 'open-api/hhService/tokenDecode'; const AppointUrl = 'zhy-api/cardBag/appoint'
// 此处第二个参数vm,就是我们在页面使用的this // 此处第二个参数vm,就是我们在页面使用的this
const install = (Vue, vm) => { const install = (Vue, vm) => {
let checkerLogin = (params = {}) => vm.$u.post(LoginUrl, params); // 登录与凭证
let sendLoginVerifyCode = (params = {}) => vm.$u.post(SendVerifyCodeUrl, params); const checkerLogin = (params = {}) => vm.$u.post(LoginUrl, params)
const sendLoginVerifyCode = (params = {}) => vm.$u.post(SendVerifyCodeUrl, params)
// xuchao: 云闪付入口 token 解码 // xuchao: 云闪付入口 token 解码
let tokenDecode = (params = {}) => vm.$u.post(TokenDecodeUrl, params); const tokenDecode = (params = {}) => vm.$u.post(TokenDecodeUrl, params)
// 文旅场馆 // 门店列表/详情
let getVenueList = (params = {}) => vm.$u.get(VenueListUrl, params); const getStoreList = (params = {}) => vm.$u.post(StoreListUrl, params)
let getVenueDetail = (params = {}) => vm.$u.post(VenueDetailUrl, params); const getStoreDetail = (params = {}) => vm.$u.post(StoreDetailUrl, params)
// 商户折扣
let getMerchantList = (params = {}) => vm.$u.get(MerchantListUrl, params);
let getMerchantDetail = (params = {}) => vm.$u.post(MerchantDetailUrl, params);
// 中奖名单
let getWinnerDates = (params = {}) => vm.$u.get(WinnerDatesUrl, params);
let getWinnerList = (params = {}) => vm.$u.post(WinnerListUrl, params);
let doAppoint = (params = {}) => vm.$u.post(AppointUrl, params);
// 权益包 // 权益包
let getRightsInfo = (params = {}) => vm.$u.get(RightsInfoUrl, params); const getRightsList = (params = {}) => vm.$u.post(RightsListUrl, params)
let getRightsDetail = (params = {}) => vm.$u.post(RightsDetailUrl, params); const getRightsDetail = (params = {}) => vm.$u.post(RightsDetailUrl, params)
let doRightsAuth = (params = {}) => vm.$u.post(RightsAuthUrl, params); // xuchao: 注意 identityNo 会触发后端“被动认证”回写,仅在认证流程中传
let doRightsBook = (params = {}) => vm.$u.post(RightsBookUrl, params); const getEligibility = (params = {}) => vm.$u.post(EligibilityUrl, params)
let getEligibility = (params = {}) => vm.$u.post(EligibilityUrl, params); const getAppointmentList = (params = {}) => vm.$u.post(AppointmentListUrl, params)
let initFaceVerify = (params = {}) => vm.$u.post(InitFaceVerifyUrl, params); const getCheckList = (params = {}) => vm.$u.post(CheckListUrl, params)
let describeFaceVerify = (params = {}) => vm.$u.post(DescribeFaceVerifyUrl, params); const doAppoint = (params = {}) => vm.$u.post(AppointUrl, params)
let getRightsList = (params = {}) => vm.$u.post(RightsListUrl, params);
let getAppointmentList = (params = {}) => vm.$u.post(AppointmentListUrl, params); // 人脸核身
let getCheckList = (params = {}) => vm.$u.post(CheckListUrl, params); const initFaceVerify = (params = {}) => vm.$u.post(InitFaceVerifyUrl, params)
let getStoreList = (params = {}) => vm.$u.post(StoreListUrl, params); const describeFaceVerify = (params = {}) => vm.$u.post(DescribeFaceVerifyUrl, params)
let getStoreDetail = (params = {}) => vm.$u.post(StoreDetailUrl, params);
// 中奖名单
const getWinnerDates = (params = {}) => vm.$u.get(WinnerDatesUrl, params)
const getWinnerList = (params = {}) => vm.$u.post(WinnerListUrl, params)
vm.$u.api = { vm.$u.api = {
checkerLogin, checkerLogin,
sendLoginVerifyCode, sendLoginVerifyCode,
tokenDecode, tokenDecode,
getVenueList,
getVenueDetail,
getMerchantList,
getMerchantDetail,
getWinnerList,
getRightsInfo,
getRightsDetail,
doRightsAuth,
doRightsBook,
getEligibility,
initFaceVerify,
describeFaceVerify,
getRightsList,
getAppointmentList,
getCheckList,
getStoreList, getStoreList,
getStoreDetail, getStoreDetail,
getWinnerDates, getRightsList,
getWinnerList, getRightsDetail,
getEligibility,
getAppointmentList,
getCheckList,
doAppoint, doAppoint,
}; initFaceVerify,
describeFaceVerify,
getWinnerDates,
getWinnerList
}
} }
export default { export default {
+12 -2
View File
@@ -1,8 +1,11 @@
import config from '@/common/config.js'
// 这里的vm,就是我们在vue文件里面的this,所以我们能在这里获取vuex的变量,比如存放在里面的token // 这里的vm,就是我们在vue文件里面的this,所以我们能在这里获取vuex的变量,比如存放在里面的token
// 同时,我们也可以在此使用getApp().globalData,如果你把token放在getApp().globalData的话,也是可以使用的 // 同时,我们也可以在此使用getApp().globalData,如果你把token放在getApp().globalData的话,也是可以使用的
const install = (Vue, vm) => { const install = (Vue, vm) => {
Vue.prototype.$u.http.setConfig({ Vue.prototype.$u.http.setConfig({
baseUrl: 'https://dscp-uat.hn12301.net', // xuchao: 基址统一取 common/config.js,避免多处硬编码
baseUrl: config.baseUrl,
// loadingText: '努力加载中~', // loadingText: '努力加载中~',
// loadingTime: 18800, // loadingTime: 18800,
// 如果将此值设置为true,拦截回调中将会返回服务端返回的所有数据response,而不是response.data // 如果将此值设置为true,拦截回调中将会返回服务端返回的所有数据response,而不是response.data
@@ -41,10 +44,17 @@ const install = (Vue, vm) => {
// 如果把originalData设置为了true,这里return回什么,this.$u.post的then回调中就会得到什么 // 如果把originalData设置为了true,这里return回什么,this.$u.post的then回调中就会得到什么
return res; return res;
}else if(res.errcode==-99){ }else if(res.errcode==-99 || res.errcode==30002){
// xuchao: 未登录/会话失效(-99 自定义、30002 框架 SESSION_AUTH_FAIL)—— 已在登录页则不再入栈
const stack = getCurrentPages() || []
const top = stack.length ? stack[stack.length - 1].route : ''
if (top !== 'pages/index/login') {
uni.navigateTo({ uni.navigateTo({
url:'/pages/index/login' url:'/pages/index/login'
}) })
}
// xuchao: 必须把 res 交回调用方,否则页面 .then(res => res.errcode) 会因 undefined 抛错
return res
}else { }else {
// uni.showToast({ // uni.showToast({
+3 -8
View File
@@ -142,15 +142,11 @@
</script> </script>
<style> <style>
page { /* xuchao: 原 page/.page 固定 100vh + overflow:hidden,小屏下活动指南第二张卡片会被裁掉且无法滚动;
height: 100%; 改为 min-height 撑满、超出可滚动,视觉在正常屏上保持不变 */
overflow: hidden;
}
.page { .page {
height: 100vh; min-height: 100vh;
background-color: #f5f5f5; background-color: #f5f5f5;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -196,7 +192,6 @@
flex: 1; flex: 1;
background-color: #ffffff; background-color: #ffffff;
margin-top: 10rpx; margin-top: 10rpx;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+34 -11
View File
@@ -43,6 +43,8 @@
</template> </template>
<script> <script>
import config from '@/common/config.js'
export default { export default {
data() { data() {
return { return {
@@ -63,6 +65,19 @@ export default {
} }
}, },
methods: { methods: {
// xuchao: open-api/pingXiang/login、sendLoginVerifyCode 返回 OpenapiResponse{code,msg,data},
// 被网关再包一层后外层 errcode 恒为 0,真正的业务码在 data.code 里。
// 只判 errcode 会把「验证码错误/过期、短信被限流」全部当成成功。
// 宽松兜底:后端若改成不返回 data.code,仍按 errcode 判定,不会把成功误判为失败。
isBizOk(res) {
if (!res || res.errcode !== 0) return false
const d = res.data
return !d || d.code === undefined || d.code === null || d.code === 200
},
// xuchao: 失败原因优先取业务 msg,其次取网关 errmsg
bizMsg(res) {
return (res && res.data && res.data.msg) || (res && res.errmsg) || ''
},
sendCode() { sendCode() {
if (this.countdown > 0) return if (this.countdown > 0) return
if (!this.phone || !/^1\d{10}$/.test(this.phone)) { if (!this.phone || !/^1\d{10}$/.test(this.phone)) {
@@ -70,14 +85,9 @@ export default {
return return
} }
this.$u.api.sendLoginVerifyCode({ smsType: 'CIB_LOGIN', mobile: this.phone }).then(res => { this.$u.api.sendLoginVerifyCode({ smsType: 'CIB_LOGIN', mobile: this.phone }).then(res => {
if (res.errcode === 0) { if (this.isBizOk(res)) {
uni.showToast({ title: '验证码已发送', icon: 'success' }) uni.showToast({ title: '验证码已发送', icon: 'success' })
} else { // xuchao: 倒计时移到发送成功之后(原来无条件启动,发送失败也会被白锁 60 秒无法重试)
uni.showToast({ title: res.errmsg || '发送失败', icon: 'none' })
}
}).catch(() => {
uni.showToast({ title: '发送失败', icon: 'none' })
})
this.countdown = 60 this.countdown = 60
this.timer = setInterval(() => { this.timer = setInterval(() => {
this.countdown-- this.countdown--
@@ -86,6 +96,13 @@ export default {
this.timer = null this.timer = null
} }
}, 1000) }, 1000)
} else {
// xuchao: 透出后端真实原因,如「操作频繁,请稍后再试」
uni.showToast({ title: this.bizMsg(res) || '发送失败', icon: 'none' })
}
}).catch(() => {
uni.showToast({ title: '发送失败', icon: 'none' })
})
}, },
onLogin() { onLogin() {
if (!this.phone || !/^1\d{10}$/.test(this.phone)) { if (!this.phone || !/^1\d{10}$/.test(this.phone)) {
@@ -100,9 +117,13 @@ export default {
uni.showToast({ title: '请同意用户协议和隐私协议', icon: 'none' }) uni.showToast({ title: '请同意用户协议和隐私协议', icon: 'none' })
return return
} }
this.$u.api.checkerLogin({ password: '1232321', mobile: this.phone, verifyCode: this.code }).then(res => { // xuchao: password 为后端要求的固定旁路口令,集中取自 config.loginFixedPassword
if (res.errcode === 0) { this.$u.api.checkerLogin({ password: config.loginFixedPassword, mobile: this.phone, verifyCode: this.code }).then(res => {
uni.setStorageSync('token', res.data.token) if (this.isBizOk(res)) {
// xuchao: 后端当前只回 userId/userName,并未下发 accessToken(字段名也是 accessToken)。
// 取到才存、取不到存空串,避免把 undefined 写进 storage 变成 "Bearer undefined"
const payload = (res.data && res.data.data) || {}
uni.setStorageSync('token', payload.accessToken || payload.token || '')
uni.setStorageSync('phone', this.phone) uni.setStorageSync('phone', this.phone)
this.$u.api.getEligibility({ phone: this.phone, identityNo: '', name: '' }).then(eRes => { this.$u.api.getEligibility({ phone: this.phone, identityNo: '', name: '' }).then(eRes => {
if (eRes.errcode === 0) { if (eRes.errcode === 0) {
@@ -125,7 +146,9 @@ export default {
} }
}) })
} else { } else {
uni.showToast({ title: res.errmsg || '登录失败', icon: 'none' }) // xuchao: 透出后端真实原因,如「验证码错误」「验证码已过期,请重新获取」;
// 失败时绝不写 phone,避免留下“假登录态”让后续页面以为已登录
uni.showToast({ title: this.bizMsg(res) || '登录失败', icon: 'none' })
} }
}).catch(() => { }).catch(() => {
uni.showToast({ title: '登录失败', icon: 'none' }) uni.showToast({ title: '登录失败', icon: 'none' })
+2 -3
View File
@@ -143,10 +143,9 @@ export default {
}).catch(() => { }).catch(() => {
uni.showToast({ title: '预约失败', icon: 'none' }) uni.showToast({ title: '预约失败', icon: 'none' })
}) })
} else {
// TODO: 调用使用接口
uni.showToast({ title: '使用成功', icon: 'success' })
} }
// xuchao: 非预约权益没有“线上使用”动作——到店由商户核销,结果体现在权益包「已使用」列表;
// 底部按钮本身也只在 detail.isAppointment 时渲染,故删除原先只弹假 Toast 的不可达分支
}, },
loadDetail(ruleId) { loadDetail(ruleId) {
const loc = uni.getStorageSync('lng') && uni.getStorageSync('lat') const loc = uni.getStorageSync('lng') && uni.getStorageSync('lat')
+130 -139
View File
@@ -107,8 +107,8 @@
</view> </view>
<!-- 权益列表 --> <!-- 权益列表 -->
<scroll-view class="list-area" scroll-y @scrolltolower="loadMore" :lower-threshold="100" <!-- xuchao: 三 Tab 列表统一改普通容器 + 页面级触底分页(scroll-view 无固定高度时 scrolltolower 永不触发) -->
v-if="currentTab==0"> <view class="list-area" v-if="currentTab==0">
<view class="rights-card" v-for="item in rightsList" :key="item.ruleId" @click="goDetail(item)"> <view class="rights-card" v-for="item in rightsList" :key="item.ruleId" @click="goDetail(item)">
<image class="card-img" :src="item.image" mode="aspectFill" /> <image class="card-img" :src="item.image" mode="aspectFill" />
<view class="card-info"> <view class="card-info">
@@ -124,28 +124,33 @@
</view> </view>
<view class="empty-state" v-if="rightsList.length === 0 && !loading"> <view class="empty-state" v-if="rightsList.length === 0 && !loading">
<image class="empty-state-img" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_wzj.png" mode="widthFix"></image> <image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
<text class="empty-state-text">暂无内容</text> <text class="empty-state-text">暂无内容</text>
</view> </view>
</scroll-view> <view v-if="pager[0].hasScrolled" class="nomore-area">
<u-loadmore :status="loadStatuses[0]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
</view>
</view>
<scroll-view class="list-area" scroll-y @scrolltolower="loadAppointmentMore" :lower-threshold="100" <view class="list-area" v-if="currentTab==1">
v-if="currentTab==1">
<view class="rights-card" style="margin-top: 24rpx;flex-direction: column;" v-for="item in appointmentList" <view class="rights-card" style="margin-top: 24rpx;flex-direction: column;" v-for="item in appointmentList"
:key="item.id" > :key="item.id" >
<view style="width: 654rpx;display: flex;justify-content: space-between;"> <view style="width: 654rpx;display: flex;justify-content: space-between;">
<text>{{ item.resourceName }}</text><text style="color: #34C759;">已预约</text></view> <text>{{ item.resourceName }}</text><text style="color: #34C759;">已预约</text></view>
<view style="font-size: 24rpx;color: #727272;margin-top: 24rpx;">预约游玩日期:{{ item.appTime.split(' ')[0] }} <!-- xuchao: appTime 为空时不再抛错拖垮整页渲染 -->
<view style="font-size: 24rpx;color: #727272;margin-top: 24rpx;">预约游玩日期:{{ (item.appTime || '').split(' ')[0] }}
<text v-if="item.timeName">({{ item.timeName }})</text></view> <text v-if="item.timeName">({{ item.timeName }})</text></view>
</view> </view>
<view class="empty-state" v-if="appointmentList.length === 0 && !appointmentLoading"> <view class="empty-state" v-if="appointmentList.length === 0 && !appointmentLoading">
<image class="empty-state-img" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_wzj.png" mode="widthFix"></image> <image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
<text class="empty-state-text">暂无内容</text> <text class="empty-state-text">暂无内容</text>
</view> </view>
</scroll-view> <view v-if="pager[1].hasScrolled" class="nomore-area">
<scroll-view class="list-area" scroll-y @scrolltolower="loadCheckMore" :lower-threshold="100" <u-loadmore :status="loadStatuses[1]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
v-if="currentTab==2"> </view>
</view>
<view class="list-area" v-if="currentTab==2">
<view class="rights-card" style="margin-top: 24rpx;flex-direction: column;" v-for="item in checkList" <view class="rights-card" style="margin-top: 24rpx;flex-direction: column;" v-for="item in checkList"
:key="item.id" > :key="item.id" >
<view style="width: 654rpx;display: flex;justify-content: space-between;"> <view style="width: 654rpx;display: flex;justify-content: space-between;">
@@ -153,16 +158,14 @@
<view style="font-size: 24rpx;color: #727272;margin-top: 24rpx;">核销时间:{{ item.checkTime }}</view> <view style="font-size: 24rpx;color: #727272;margin-top: 24rpx;">核销时间:{{ item.checkTime }}</view>
</view> </view>
<view class="empty-state" v-if="checkList.length === 0 && !checkLoading"> <view class="empty-state" v-if="checkList.length === 0 && !checkLoading">
<image class="empty-state-img" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_wzj.png" mode="widthFix"></image> <image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
<text class="empty-state-text">暂无内容</text> <text class="empty-state-text">暂无内容</text>
</view> </view>
<view class="loading" v-if="checkLoading"> <!-- xuchao: 原先自写的“加载中/没有更多”与另两个 Tab 不一致,统一换成 u-loadmore -->
<text class="loading-text">加载中...</text> <view v-if="pager[2].hasScrolled" class="nomore-area">
<u-loadmore :status="loadStatuses[2]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
</view> </view>
<view class="no-more" v-else-if="checkList.length >= checkTotal && checkList.length > 0">
<text class="no-more-text">没有更多了</text>
</view> </view>
</scroll-view>
</view> </view>
</view> </view>
</template> </template>
@@ -187,20 +190,18 @@
currentTab: 0, currentTab: 0,
keyword: '', keyword: '',
rightsList: [], rightsList: [],
pageNo: 1,
pageSize: 1000,
total: 0,
lng: '',
lat: '',
loading: false,
appointmentList: [], appointmentList: [],
appointmentPageNo: 1,
appointmentTotal: 0,
appointmentLoading: false,
checkList: [], checkList: [],
checkPageNo: 1, // xuchao: 三个 Tab 各自独立的分页游标/加载态。原 pageSize=1000 是“一次拉全量 +
checkTotal: 0, // scroll-view 永不触发的假分页”,现改真实分页(与场馆/折扣页口径一致)
checkLoading: false pageSize: 10,
pager: [
{ pageNo: 1, total: 0, loading: false, reqSeq: 0, hasScrolled: false },
{ pageNo: 1, total: 0, loading: false, reqSeq: 0, hasScrolled: false },
{ pageNo: 1, total: 0, loading: false, reqSeq: 0, hasScrolled: false }
],
lng: '',
lat: ''
} }
}, },
computed: { computed: {
@@ -208,6 +209,24 @@
totalValueText() { totalValueText() {
const yuan = this.totalValueFen / 100 const yuan = this.totalValueFen / 100
return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2) return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2)
},
// xuchao: 模板沿用原有 loading / appointmentLoading / checkLoading 字段名
loading() {
return this.pager[0].loading
},
appointmentLoading() {
return this.pager[1].loading
},
checkLoading() {
return this.pager[2].loading
},
// xuchao: 各 Tab 底部“加载中/没有更多”独立计算,避免并行请求互相覆盖
loadStatuses() {
return this.pager.map((p, i) => {
if (p.loading) return 'loading'
const len = this.listOf(i).length
return (len === 0 || len >= p.total) ? 'nomore' : 'loadmore'
})
} }
}, },
onLoad(options) { onLoad(options) {
@@ -231,6 +250,17 @@
this.loadRightsList() this.loadRightsList()
} }
}, },
// xuchao: 页面级触底加载 —— 只作用于当前 Tab
onReachBottom() {
if (this.status !== 'info') return
const tab = this.currentTab
const p = this.pager[tab]
p.hasScrolled = true
if (!p.loading && this.listOf(tab).length < p.total) {
p.pageNo++
this.loadList(tab)
}
},
methods: { methods: {
// xuchao: 缓存的权益状态可能过期(中奖/权益包会变化),每次进页面仅凭手机号向服务端 // xuchao: 缓存的权益状态可能过期(中奖/权益包会变化),每次进页面仅凭手机号向服务端
// 复查最新状态并覆盖缓存(不带证件号,避免触发被动认证) // 复查最新状态并覆盖缓存(不带证件号,避免触发被动认证)
@@ -427,110 +457,86 @@
}) })
}) })
}, },
loadRightsList() { // xuchao: Tab 索引 <-> 列表字段的唯一映射
const cardNumber = uni.getStorageSync('cardNumber') || '' listOf(tab) {
this.fetchRightsList(cardNumber) return tab === 1 ? this.appointmentList : tab === 2 ? this.checkList : this.rightsList
}, },
fetchRightsList(cardNumber) { setList(tab, list) {
if (this.loading) return if (tab === 1) {
this.loading = true this.appointmentList = list
this.$u.api.getRightsList({ } else if (tab === 2) {
pageNo: this.pageNo, this.checkList = list
} else {
this.rightsList = list
}
},
// xuchao: 三个 Tab 的统一分页加载器(reset=true 回到第一页重拉)
loadList(tab, reset) {
const p = this.pager[tab]
if (reset) {
p.pageNo = 1
p.total = 0
this.setList(tab, [])
}
// xuchao: 请求序号 —— 新请求取代在途请求,过期响应整体丢弃(治搜索/切 Tab 竞态)
const seq = ++p.reqSeq
p.loading = true
const params = {
pageNo: p.pageNo,
pageSize: this.pageSize, pageSize: this.pageSize,
keyword: this.keyword, cardNumber: uni.getStorageSync('cardNumber') || ''
lng: this.lng, }
lat: this.lat, let request
cardNumber: cardNumber if (tab === 1) {
}).then(res => { request = this.$u.api.getAppointmentList(params)
} else if (tab === 2) {
request = this.$u.api.getCheckList(params)
} else {
params.keyword = this.keyword
params.lng = this.lng
params.lat = this.lat
request = this.$u.api.getRightsList(params)
}
request.then(res => {
if (seq !== p.reqSeq) return
if (res.errcode === 0 && res.data) { if (res.errcode === 0 && res.data) {
const list = res.data.list || [] const list = res.data.list || []
if (this.pageNo === 1) { const prev = this.listOf(tab)
this.rightsList = list // xuchao: 按 id/ruleId 去重合并 —— 兜住后端忽略 pageNo 重复返回同一页的情况
const seen = {}
const merged = (p.pageNo === 1 ? list : prev.concat(list)).filter(item => {
const k = (item.id === undefined || item.id === null) ? item.ruleId : item.id
if (k === undefined || k === null) return true
if (seen[k]) return false
seen[k] = true
return true
})
// xuchao: 本页不足一页 或 整页都是重复 => 视为已到尾部,钉住 total,
// 防止 total 中途变大时反复空翻/空转
if (list.length < this.pageSize || (p.pageNo !== 1 && merged.length === prev.length)) {
p.total = merged.length
} else { } else {
this.rightsList = this.rightsList.concat(list) p.total = res.data.total || 0
} }
this.total = res.data.total || 0 this.setList(tab, merged)
} }
}).finally(() => { }).finally(() => {
this.loading = false if (seq !== p.reqSeq) return
p.loading = false
}) })
}, },
loadMore() { loadRightsList() {
if (this.rightsList.length < this.total && !this.loading) { this.loadList(0, true)
this.pageNo++
this.loadRightsList()
}
}, },
switchTab(index) { switchTab(index) {
this.currentTab = index this.currentTab = index
if (index === 1 && this.appointmentList.length === 0) { // xuchao: 首次进入该 Tab 才拉首页;已有数据保留各自游标继续分页
this.appointmentPageNo = 1 if (this.listOf(index).length === 0 && !this.pager[index].loading) {
this.loadAppointmentList() this.loadList(index, true)
}
if (index === 2 && this.checkList.length === 0) {
this.checkPageNo = 1
this.loadCheckList()
}
},
loadAppointmentList() {
const cardNumber = uni.getStorageSync('cardNumber') || ''
if (this.appointmentLoading) return
this.appointmentLoading = true
this.$u.api.getAppointmentList({
pageNo: this.appointmentPageNo,
pageSize: this.pageSize,
cardNumber: cardNumber
}).then(res => {
if (res.errcode === 0 && res.data) {
const list = res.data.list || []
if (this.appointmentPageNo === 1) {
this.appointmentList = list
} else {
this.appointmentList = this.appointmentList.concat(list)
}
this.appointmentTotal = res.data.total || 0
}
}).finally(() => {
this.appointmentLoading = false
})
},
loadAppointmentMore() {
if (this.appointmentList.length < this.appointmentTotal && !this.appointmentLoading) {
this.appointmentPageNo++
this.loadAppointmentList()
}
},
loadCheckList() {
const cardNumber = uni.getStorageSync('cardNumber') || ''
if (this.checkLoading) return
this.checkLoading = true
this.$u.api.getCheckList({
pageNo: this.checkPageNo,
pageSize: this.pageSize,
cardNumber: cardNumber
}).then(res => {
if (res.errcode === 0 && res.data) {
const list = res.data.list || []
if (this.checkPageNo === 1) {
this.checkList = list
} else {
this.checkList = this.checkList.concat(list)
}
this.checkTotal = res.data.total || 0
}
}).finally(() => {
this.checkLoading = false
})
},
loadCheckMore() {
if (this.checkList.length < this.checkTotal && !this.checkLoading) {
this.checkPageNo++
this.loadCheckList()
} }
}, },
onSearch() { onSearch() {
this.pageNo = 1 this.loadList(0, true)
this.loadRightsList()
}, },
goDetail(item) { goDetail(item) {
uni.navigateTo({ uni.navigateTo({
@@ -847,12 +853,17 @@
} }
/* 权益列表 */ /* 权益列表 */
/* xuchao: 原 scroll-view 下 gap 不生效(卡片被包在 scroll-view-content 内),
改普通容器后去掉 gap,卡片间距仍由 .rights-card 的 margin-bottom 提供,视觉不变 */
.list-area { .list-area {
width: 750rpx; width: 750rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; }
.nomore-area {
width: 100%;
padding-bottom: 24px;
} }
.rights-card { .rights-card {
@@ -924,26 +935,6 @@
color: #999999; color: #999999;
} }
.loading {
text-align: center;
padding: 16px 0;
}
.loading-text {
font-size: 14px;
color: #999999;
}
.no-more {
text-align: center;
padding: 16px 0;
}
.no-more-text {
font-size: 14px;
color: #999999;
}
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+23 -15
View File
@@ -8,12 +8,12 @@
<text class="back-text">返回</text> <text class="back-text">返回</text>
</view> </view>
<view class="banner-header"> <view class="banner-header">
<image class="banner-icon" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_zjgs.png" mode="aspectFit"></image> <image class="banner-icon" src="https://aliyunoss.hn12301.net/hhrs/hhrs_zjgs.png" mode="aspectFit"></image>
<text class="banner-title">中奖名单公示</text> <text class="banner-title">中奖名单公示</text>
</view> </view>
<text class="banner-subtitle">总计 10,000份权益包,分三期公证抽取</text> <text class="banner-subtitle">总计 10,000份权益包,分三期公证抽取</text>
<view class="stats-card"> <view class="stats-card">
<image class="stats-gift" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_xyckr.png" mode="aspectFit"></image> <image class="stats-gift" src="https://aliyunoss.hn12301.net/hhrs/hhrs_xyckr.png" mode="aspectFit"></image>
<text class="stats-label">已产生</text> <text class="stats-label">已产生</text>
<text class="stats-number">{{ totalWinners.toLocaleString() }}</text> <text class="stats-number">{{ totalWinners.toLocaleString() }}</text>
<text class="stats-label">名幸运持卡人</text> <text class="stats-label">名幸运持卡人</text>
@@ -78,7 +78,8 @@ export default {
currentTab: 0, currentTab: 0,
tabs: [], tabs: [],
winnerData: {}, winnerData: {},
pageNo: 1, // xuchao: 页码改为按期维护(原单一 pageNo 在切期后会被沿用,导致同一页重复追加/跳页)
pageNos: {},
pageSize: 10, pageSize: 10,
totals: {}, totals: {},
totalWinners: 0, totalWinners: 0,
@@ -123,11 +124,13 @@ export default {
data[index] = [] data[index] = []
}) })
this.winnerData = data this.winnerData = data
// xuchao: 预置各期 key,保证 totals/loadStatusMap 的响应式 // xuchao: 预置各期 key,保证 totals/pageNos/loadStatusMap 的响应式
this.totals = {} this.totals = {}
this.pageNos = {}
this.loadStatusMap = {} this.loadStatusMap = {}
dates.forEach((date, index) => { dates.forEach((date, index) => {
this.$set(this.totals, index, 0) this.$set(this.totals, index, 0)
this.$set(this.pageNos, index, 1)
this.$set(this.loadStatusMap, index, 'loading') this.$set(this.loadStatusMap, index, 'loading')
}) })
// xuchao: 预取各期第一页,汇总已产生总人数 // xuchao: 预取各期第一页,汇总已产生总人数
@@ -142,21 +145,24 @@ export default {
loadWinnerList(tabIndex, pageNo, allowConcurrent) { loadWinnerList(tabIndex, pageNo, allowConcurrent) {
if (!this.tabs[tabIndex]) return if (!this.tabs[tabIndex]) return
if (this.inFlight > 0 && !allowConcurrent) return if (this.inFlight > 0 && !allowConcurrent) return
// xuchao: 本期页码独立计算,缺省续拉该期下一页
const page = pageNo || (this.pageNos[tabIndex] || 1)
this.inFlight++ this.inFlight++
this.$set(this.loadStatusMap, tabIndex, 'loading') this.$set(this.loadStatusMap, tabIndex, 'loading')
const drawDate = this.tabs[tabIndex].date const drawDate = this.tabs[tabIndex].date
this.$u.api.getWinnerList({ this.$u.api.getWinnerList({
pageNo: pageNo || this.pageNo, pageNo: page,
pageSize: this.pageSize, pageSize: this.pageSize,
drawDate: drawDate drawDate: drawDate
}).then(res => { }).then(res => {
if (res.errcode === 0 && res.data) { if (res.errcode === 0 && res.data) {
const list = res.data.list || [] const list = res.data.list || []
const page = pageNo || this.pageNo const prev = this.winnerData[tabIndex] || []
this.$set(this.pageNos, tabIndex, page)
if (page === 1) { if (page === 1) {
this.$set(this.winnerData, tabIndex, list) this.$set(this.winnerData, tabIndex, list)
} else { } else {
this.winnerData[tabIndex] = this.winnerData[tabIndex].concat(list) this.$set(this.winnerData, tabIndex, prev.concat(list))
} }
this.$set(this.totals, tabIndex, res.data.total || 0) this.$set(this.totals, tabIndex, res.data.total || 0)
// xuchao: 各期总人数求和,用于顶部幸运持卡人数 // xuchao: 各期总人数求和,用于顶部幸运持卡人数
@@ -164,23 +170,25 @@ export default {
} }
}).finally(() => { }).finally(() => {
this.inFlight-- this.inFlight--
const len = this.winnerData[tabIndex].length const len = (this.winnerData[tabIndex] || []).length
const total = this.totals[tabIndex] || 0 const total = this.totals[tabIndex] || 0
this.$set(this.loadStatusMap, tabIndex, len === 0 || len >= total ? 'nomore' : 'loadmore') this.$set(this.loadStatusMap, tabIndex, len === 0 || len >= total ? 'nomore' : 'loadmore')
}) })
}, },
loadMore() { loadMore() {
const total = this.totals[this.currentTab] || 0 const tab = this.currentTab
if (this.winnerData[this.currentTab].length < total && this.inFlight === 0) { const total = this.totals[tab] || 0
this.pageNo++ const len = (this.winnerData[tab] || []).length
this.loadWinnerList(this.currentTab) if (len < total && this.inFlight === 0) {
this.loadWinnerList(tab, (this.pageNos[tab] || 1) + 1)
} }
}, },
switchTab(index) { switchTab(index) {
this.currentTab = index this.currentTab = index
this.pageNo = 1 // xuchao: 不再重置全局 pageNo(各期游标自带);空列表补拉首页时允许并发,
if (this.winnerData[index].length === 0) { // 避免首屏预取仍在途时切期被丢弃后一直显示“暂无数据”
this.loadWinnerList(index, 1) if ((this.winnerData[index] || []).length === 0) {
this.loadWinnerList(index, 1, true)
} else { } else {
const total = this.totals[index] || 0 const total = this.totals[index] || 0
this.$set(this.loadStatusMap, index, this.winnerData[index].length >= total ? 'nomore' : 'loadmore') this.$set(this.loadStatusMap, index, this.winnerData[index].length >= total ? 'nomore' : 'loadmore')