- 三个列表(文旅场馆/商户折扣/权益待使用)缩略图左上角增加所属地区角标: 缩略图外套 .card-thumb 承担尺寸与圆角裁切,角标半透明黑底白字 10px; districtName 为空的记录不渲染角标,避免空色块 - 新增 common/district.js:后端返回的是行政区划全称,卡片位宽只有 75px, 按「芷江侗族自治县→芷江、中方县→中方、怀化高新技术产业开发区→怀化高新区」缩短显示 - 筛选条与列表的 12px 间隔移到头部白底之外(各页 .list-area 顶部内边距), 此前放在头部内是白色、与白色卡片连成一片,视觉上等于没有间隔 - 遮罩由 fixed 改为从筛选条下边缘绝对定位铺开:fixed 会落进 sticky 头部的层叠上下文, 把搜索栏和筛选条一起压灰;现改为列表变暗、头部保持原色 - 权益页搜索框加 1px #e5e5ea 描边:吸顶栏改白底后白色输入框无边框可辨 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1092 lines
30 KiB
Vue
1092 lines
30 KiB
Vue
<template>
|
||
<view class="page" :class="{ 'page-auth': status === 'auth' }">
|
||
<!-- xuchao: 顶部导航(返回+页面名称,认证状态使用) -->
|
||
<view class="nav-bar" v-if="status === 'auth'">
|
||
<view class="nav-back" @click="goBack">
|
||
<u-icon name="arrow-left" color="#1a1818" size="40"></u-icon>
|
||
</view>
|
||
<text class="nav-title">权益资格认证</text>
|
||
</view>
|
||
<!-- 返回按钮(顶部,非auth状态) -->
|
||
<view class="back-btn" :class="{ 'back-btn--black': status === 'noWin' }" @click="goBack" v-if="status !== 'auth'">
|
||
<u-icon name="arrow-left" :color="status === 'noWin' ? '#000000' : '#ffffff'" size="40"></u-icon>
|
||
<text class="back-text">返回</text>
|
||
</view>
|
||
<!-- 状态1: 权益资格认证 -->
|
||
<view v-if="status === 'auth'" class="auth-container">
|
||
<view class="section-title">
|
||
<view class="title-bar"></view>
|
||
<text class="title-text">权益资格认证</text>
|
||
</view>
|
||
<view class="form-area">
|
||
<view class="form-item">
|
||
<text class="form-label required">真实姓名</text>
|
||
<input class="form-input" v-model="form.realName" placeholder="请输入真实姓名" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label required">手机号码</text>
|
||
<text class="form-value phone-value">{{ form.phone }}</text>
|
||
</view>
|
||
<view class="form-item" @click="showIdTypePicker = true">
|
||
<text class="form-label required">证件类型</text>
|
||
<text class="form-value">{{ form.idType }}</text>
|
||
<text class="form-arrow"></text>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label required">证件号码</text>
|
||
<input class="form-input" v-model="form.idNumber" placeholder="请输入真实证件号码" />
|
||
</view>
|
||
</view>
|
||
<view class="auth-btn" @click="onAuth">开始认证</view>
|
||
<view class="tips-section">
|
||
<view class="section-title">
|
||
<view class="title-bar"></view>
|
||
<text class="title-text">温馨提示</text>
|
||
</view>
|
||
<view class="tips-list">
|
||
<text class="tip-item">1. 请输入真实的身份信息用来进行活动权益资格认证</text>
|
||
<text class="tip-item">2. 认证通过后系统会显示出您的所获得的权益信息</text>
|
||
<text class="tip-item">3. 手机号码为您登录的手机号,无法修改。若号码不对,请更换手机号重新登录。</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 状态2: 未中奖 -->
|
||
<view v-if="status === 'noWin'" class="empty-container">
|
||
<image class="empty-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix" />
|
||
<text class="empty-text">您暂未中奖</text>
|
||
</view>
|
||
|
||
<!-- 状态3: 权益信息 -->
|
||
<view v-if="status === 'info'">
|
||
<!-- 蓝色头部横幅 -->
|
||
<view style="background: #fff;">
|
||
<view class="banner-area">
|
||
<text class="banner-title">恭喜您!获得怀化市社保卡权益包</text>
|
||
<view class="banner-expire" v-if="expiresTime">
|
||
<text class="expire-icon">!</text>
|
||
<text class="expire-text">{{ expiresTime }}</text>
|
||
<text class="expire-label">到期</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 权益统计卡片 -->
|
||
<view class="stats-card">
|
||
<view class="stats-item">
|
||
<text class="stats-label">总权益价值</text>
|
||
<view class="stats-value-row">
|
||
<text class="stats-currency">¥</text>
|
||
<text class="stats-number">{{ totalValueText }}</text>
|
||
<text class="stats-unit">元</text>
|
||
</view>
|
||
</view>
|
||
<view class="stats-divider"></view>
|
||
<view class="stats-item">
|
||
<text class="stats-label">可用权益数</text>
|
||
<view class="stats-value-row">
|
||
<text class="stats-number-large">{{ rightsCount }}</text>
|
||
<text class="stats-unit">个</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- xuchao: Tab + 搜索 + 县区/类型筛选一起吸顶;筛选只作用于「待使用」(另两个 Tab 的接口不返回地址与分类) -->
|
||
<view class="info-sticky">
|
||
<!-- Tab 切换 -->
|
||
<view class="tab-bar">
|
||
<view v-for="(tab, index) in tabs" :key="index" class="tab-item"
|
||
:class="{ active: currentTab === index }" @click="switchTab(index)">
|
||
<text class="tab-text">{{ tab }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- 搜索栏 -->
|
||
<view class="search-area" v-if="currentTab==0">
|
||
<view class="search-bar">
|
||
<u-icon name="search" color="#cccccc" size="24"></u-icon>
|
||
<input class="search-input" v-model="keyword" placeholder="搜索权益商家" @confirm="onSearch" />
|
||
</view>
|
||
<!-- xuchao: 所属县区 + 所属类型筛选,选项与联动条数由列表接口一并返回 -->
|
||
<hhrs-filter-bar :districts="filters.districts" :categories="filters.categories"
|
||
:district-id="districtId" :category="category" @change="onFilterChange" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 权益列表 -->
|
||
<!-- xuchao: 三 Tab 列表统一改普通容器 + 页面级触底分页(scroll-view 无固定高度时 scrolltolower 永不触发) -->
|
||
<view class="list-area list-area--todo" v-if="currentTab==0">
|
||
<view class="rights-card" v-for="item in rightsList" :key="item.ruleId" @click="goDetail(item)">
|
||
<view class="card-thumb">
|
||
<image class="card-img" :src="item.image" mode="aspectFill" />
|
||
<!-- xuchao: 左上角所属地区角标(districtName 来自商铺「所在地区」,未录入则不显示) -->
|
||
<text class="card-area" v-if="item.districtName">{{ shortDistrict(item.districtName) }}</text>
|
||
</view>
|
||
<view class="card-info">
|
||
<view class="card-name">{{ item.name }}</view>
|
||
<view class="card-category">{{ item.category }}</view>
|
||
<view class="card-address">{{ item.address }}</view>
|
||
</view>
|
||
<view class="card-distance" v-if="item.distance !== null">
|
||
<u-icon name="map" color="#999999" size="24"></u-icon>
|
||
<text
|
||
class="distance-text">{{ item.distance < 1 ? Math.round(item.distance * 1000) + 'm' : Math.round(item.distance) + 'km' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="empty-state" v-if="rightsList.length === 0 && !loading">
|
||
<image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
|
||
<text class="empty-state-text">暂无内容</text>
|
||
</view>
|
||
<view v-if="pager[0].hasScrolled" class="nomore-area">
|
||
<u-loadmore :status="loadStatuses[0]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="list-area" v-if="currentTab==1">
|
||
<view class="rights-card" style="margin-top: 24rpx;flex-direction: column;" v-for="item in appointmentList"
|
||
:key="item.id" >
|
||
<view style="width: 654rpx;display: flex;justify-content: space-between;">
|
||
<text>{{ item.resourceName }}</text><text style="color: #34C759;">已预约</text></view>
|
||
<!-- 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>
|
||
</view>
|
||
|
||
<view class="empty-state" v-if="appointmentList.length === 0 && !appointmentLoading">
|
||
<image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
|
||
<text class="empty-state-text">暂无内容</text>
|
||
</view>
|
||
<view v-if="pager[1].hasScrolled" class="nomore-area">
|
||
<u-loadmore :status="loadStatuses[1]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
|
||
</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"
|
||
:key="item.id" >
|
||
<view style="width: 654rpx;display: flex;justify-content: space-between;">
|
||
<text>{{ item.resourceName }}</text><text style="color: #FF383C;">已使用</text></view>
|
||
<view style="font-size: 24rpx;color: #727272;margin-top: 24rpx;">核销时间:{{ item.checkTime }}</view>
|
||
</view>
|
||
<view class="empty-state" v-if="checkList.length === 0 && !checkLoading">
|
||
<image class="empty-state-img" src="https://aliyunoss.hn12301.net/hhrs/hhrs_wzj.png" mode="widthFix"></image>
|
||
<text class="empty-state-text">暂无内容</text>
|
||
</view>
|
||
<!-- xuchao: 原先自写的“加载中/没有更多”与另两个 Tab 不一致,统一换成 u-loadmore -->
|
||
<view v-if="pager[2].hasScrolled" class="nomore-area">
|
||
<u-loadmore :status="loadStatuses[2]" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
// xuchao: 角标区县简称(后端返回的是行政区划全称)
|
||
import { shortDistrict } from '@/common/district.js'
|
||
// xuchao: 县区 + 类型筛选条,三个列表页共用(pages.json 的 easycom 只给 u- 前缀配了规则,这里显式引入)
|
||
import HhrsFilterBar from '@/components/hhrs-filter-bar/hhrs-filter-bar.vue'
|
||
|
||
export default {
|
||
components: { HhrsFilterBar },
|
||
data() {
|
||
return {
|
||
status: 'auth',
|
||
// xuchao: 权益概览三字段,首屏用缓存兜底,随后由服务端复查刷新
|
||
expiresTime: uni.getStorageSync('expiresTime') || '',
|
||
rightsCount: uni.getStorageSync('rightsCount') || 0,
|
||
totalValueFen: uni.getStorageSync('totalValueFen') || 0,
|
||
form: {
|
||
realName: '',
|
||
phone: '',
|
||
idType: '居民身份证',
|
||
idNumber: ''
|
||
},
|
||
showIdTypePicker: false,
|
||
tabs: ['待使用', '已预约', '已使用'],
|
||
currentTab: 0,
|
||
keyword: '',
|
||
// xuchao: 待使用列表的县区/类型筛选条件与面板选项(选项由列表响应带回)
|
||
districtId: '',
|
||
category: '',
|
||
filters: { districts: [], categories: [] },
|
||
// xuchao: 待使用列表的县区/类型筛选条件与面板选项(选项由列表响应带回)
|
||
districtId: '',
|
||
category: '',
|
||
filters: { districts: [], categories: [] },
|
||
rightsList: [],
|
||
appointmentList: [],
|
||
checkList: [],
|
||
// xuchao: 三个 Tab 各自独立的分页游标/加载态。原 pageSize=1000 是“一次拉全量 +
|
||
// scroll-view 永不触发的假分页”,现改真实分页(与场馆/折扣页口径一致)
|
||
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: {
|
||
// xuchao: 总权益价值分转元,整除去小数,否则保留两位
|
||
totalValueText() {
|
||
const yuan = this.totalValueFen / 100
|
||
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) {
|
||
if (options.status) {
|
||
this.status = options.status
|
||
}
|
||
this.form.phone = uni.getStorageSync('phone') || ''
|
||
const loc = uni.getStorageSync('lng') && uni.getStorageSync('lat') ? {
|
||
lng: uni.getStorageSync('lng'),
|
||
lat: uni.getStorageSync('lat')
|
||
} :
|
||
null
|
||
if (loc) {
|
||
this.lng = loc.lng
|
||
this.lat = loc.lat
|
||
}
|
||
this.checkAuthResult()
|
||
// xuchao: 权益状态会变化,缓存只作首屏展示,进入页面一律向服务端复查最新状态
|
||
this.refreshStatus()
|
||
if (this.status === 'info') {
|
||
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: {
|
||
shortDistrict,
|
||
// xuchao: 缓存的权益状态可能过期(中奖/权益包会变化),每次进页面仅凭手机号向服务端
|
||
// 复查最新状态并覆盖缓存(不带证件号,避免触发被动认证)
|
||
refreshStatus() {
|
||
// xuchao: 人脸认证回调场景由 checkAuthResult 负责查询,跳过避免重复请求
|
||
try {
|
||
if (new URL(window.location.href).searchParams.get('response')) return
|
||
} catch (e) {
|
||
return
|
||
}
|
||
// xuchao: 用户主动发起过认证流程时,取消后应停留在认证页,不自动恢复
|
||
if (this.status === 'auth' && uni.getStorageSync('authFlowStarted')) return
|
||
const phone = uni.getStorageSync('phone')
|
||
if (!phone) return
|
||
this.$u.api.getEligibility({
|
||
phone: phone
|
||
}).then(res => {
|
||
if (res.errcode !== 0 || !res.data) return
|
||
this.applyEligibility(res.data)
|
||
// xuchao: 服务端已认证则清除取消标记,后续按真实状态展示
|
||
if (res.data.isAuth) uni.removeStorageSync('authFlowStarted')
|
||
const newStatus = res.data.isAuth ? (res.data.hasWin ? 'info' : 'noWin') : 'auth'
|
||
const prevStatus = this.status
|
||
this.status = newStatus
|
||
// xuchao: 状态刷新为权益页时补拉权益列表,避免与首屏加载重复
|
||
if (newStatus === 'info' && prevStatus !== 'info') {
|
||
this.loadRightsList()
|
||
}
|
||
})
|
||
},
|
||
// xuchao: 资格查询结果统一写入缓存并回填页面(到期时间/总权益价值/可用权益数)
|
||
applyEligibility(data) {
|
||
uni.setStorageSync('isAuth', data.isAuth)
|
||
uni.setStorageSync('hasWin', data.hasWin)
|
||
uni.setStorageSync('cardNumber', data.cardNumber)
|
||
uni.setStorageSync('expiresTime', data.expiresTime)
|
||
uni.setStorageSync('rightsCount', data.rightsCount || 0)
|
||
uni.setStorageSync('totalValueFen', data.totalValueFen || 0)
|
||
this.expiresTime = data.expiresTime || ''
|
||
this.rightsCount = data.rightsCount || 0
|
||
this.totalValueFen = data.totalValueFen || 0
|
||
},
|
||
// xuchao: 认证取消/未通过:清理旧认证缓存并标记流程已发起,取消后应停留认证页
|
||
markAuthCanceled() {
|
||
uni.removeStorageSync('isAuth')
|
||
uni.removeStorageSync('hasWin')
|
||
uni.removeStorageSync('cardNumber')
|
||
uni.removeStorageSync('expiresTime')
|
||
uni.removeStorageSync('rightsCount')
|
||
uni.removeStorageSync('totalValueFen')
|
||
uni.setStorageSync('authFlowStarted', true)
|
||
},
|
||
checkAuthResult() {
|
||
const url = new URL(window.location.href)
|
||
let responseStr = url.searchParams.get('response')
|
||
console.log('responseStr:', responseStr)
|
||
if (!responseStr) return
|
||
let response
|
||
try {
|
||
let parms = JSON.parse(decodeURIComponent(responseStr))
|
||
console.log('parms:', parms)
|
||
response = typeof parms === 'string' ? JSON.parse(parms) : parms
|
||
console.log('response:', response)
|
||
} catch (e) {
|
||
try {
|
||
response = JSON.parse(responseStr)
|
||
} catch (e2) {
|
||
return
|
||
}
|
||
}
|
||
const code = response.code
|
||
const certifyId = response.extInfo ? response.extInfo.certifyId : ''
|
||
console.log('code:', code, 'certifyId:', certifyId)
|
||
if (code == 1000 && certifyId) {
|
||
this.$u.api.describeFaceVerify({
|
||
certifyId: certifyId
|
||
}).then(res => {
|
||
console.log('describeFaceVerify res:', res)
|
||
if (res.errcode === 0 && res.data.body && res.data.body.resultObject) {
|
||
const verifyResult = res.data.body.resultObject
|
||
console.log('verifyResult:', verifyResult)
|
||
if (String(verifyResult.passed) === 'T' || String(verifyResult.Passed) === 'T') {
|
||
const phone = uni.getStorageSync('phone') || ''
|
||
const name = uni.getStorageSync('authName') || ''
|
||
const identityNo = uni.getStorageSync('authIdNo') || ''
|
||
this.$u.api.getEligibility({
|
||
phone: phone,
|
||
identityNo: identityNo,
|
||
name: name
|
||
}).then(eRes => {
|
||
console.log('eligibility res:', eRes)
|
||
if (eRes.errcode === 0) {
|
||
this.applyEligibility(eRes.data)
|
||
// xuchao: 认证成功,清除流程标记
|
||
uni.removeStorageSync('authFlowStarted')
|
||
if (eRes.data.isAuth) {
|
||
this.status = eRes.data.hasWin ? 'info' : 'noWin'
|
||
if (this.status === 'info') {
|
||
this.loadRightsList()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
// xuchao: 实名未通过视为取消认证,清理缓存并标记
|
||
this.markAuthCanceled()
|
||
uni.showToast({
|
||
title: '认证未通过',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
} else {
|
||
uni.showToast({
|
||
title: '认证结果查询失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}).catch(() => {
|
||
uni.showToast({
|
||
title: '认证结果查询失败',
|
||
icon: 'none'
|
||
})
|
||
})
|
||
} else {
|
||
// xuchao: 未返回认证回调视为取消认证,清理缓存并标记
|
||
this.markAuthCanceled()
|
||
uni.showToast({
|
||
title: '认证未通过',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
loadAliyunSDK() {
|
||
return new Promise((resolve, reject) => {
|
||
if (window.getMetaInfo) {
|
||
resolve()
|
||
return
|
||
}
|
||
const script = document.createElement('script')
|
||
script.src = 'https://o.alicdn.com/yd-cloudauth/cloudauth-cdn/jsvm_all.js'
|
||
script.onload = () => resolve()
|
||
script.onerror = () => reject(new Error('加载阿里云SDK失败'))
|
||
document.head.appendChild(script)
|
||
})
|
||
},
|
||
onAuth() {
|
||
if (!this.form.realName) {
|
||
uni.showToast({
|
||
title: '请输入真实姓名',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
if (!this.form.idNumber) {
|
||
uni.showToast({
|
||
title: '请输入证件号码',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
uni.setStorageSync('authName', this.form.realName)
|
||
uni.setStorageSync('authIdNo', this.form.idNumber)
|
||
// xuchao: 标记已发起认证流程,取消后停留在认证页,不自动恢复
|
||
uni.setStorageSync('authFlowStarted', true)
|
||
this.loadAliyunSDK().then(() => {
|
||
const metaInfo = JSON.stringify(window.getMetaInfo())
|
||
const returnUrl = window.location.origin + window.location.pathname +
|
||
'?response=%7B%7D#/pages/rights/rights?status=auth'
|
||
this.$u.api.initFaceVerify({
|
||
certName: this.form.realName,
|
||
certNo: this.form.idNumber,
|
||
metaInfo: metaInfo,
|
||
returnUrl: returnUrl
|
||
}).then(res => {
|
||
if (res.errcode === 0 && res.data.body && res.data.body.resultObject && res.data
|
||
.body.resultObject.certifyUrl) {
|
||
window.location.href = res.data.body.resultObject.certifyUrl
|
||
} else {
|
||
uni.showToast({
|
||
title: res.errmsg || '认证初始化失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}).catch(() => {
|
||
uni.showToast({
|
||
title: '认证初始化失败',
|
||
icon: 'none'
|
||
})
|
||
})
|
||
}).catch(() => {
|
||
uni.showToast({
|
||
title: '加载认证组件失败',
|
||
icon: 'none'
|
||
})
|
||
})
|
||
},
|
||
// xuchao: Tab 索引 <-> 列表字段的唯一映射
|
||
listOf(tab) {
|
||
return tab === 1 ? this.appointmentList : tab === 2 ? this.checkList : this.rightsList
|
||
},
|
||
setList(tab, list) {
|
||
if (tab === 1) {
|
||
this.appointmentList = list
|
||
} else if (tab === 2) {
|
||
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,
|
||
cardNumber: uni.getStorageSync('cardNumber') || ''
|
||
}
|
||
let request
|
||
if (tab === 1) {
|
||
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
|
||
if (this.districtId) params.districtId = this.districtId
|
||
if (this.category) params.category = this.category
|
||
request = this.$u.api.getRightsList(params)
|
||
}
|
||
request.then(res => {
|
||
if (seq !== p.reqSeq) return
|
||
// xuchao: 筛选面板选项只随「待使用」列表返回,另两个 Tab 不覆盖
|
||
if (tab === 0 && res.errcode === 0 && res.data) {
|
||
this.filters = {
|
||
districts: res.data.districts || [],
|
||
categories: res.data.categories || []
|
||
}
|
||
}
|
||
if (res.errcode === 0 && res.data) {
|
||
const list = res.data.list || []
|
||
const prev = this.listOf(tab)
|
||
// 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 {
|
||
p.total = res.data.total || 0
|
||
}
|
||
this.setList(tab, merged)
|
||
}
|
||
}).finally(() => {
|
||
if (seq !== p.reqSeq) return
|
||
p.loading = false
|
||
})
|
||
},
|
||
loadRightsList() {
|
||
this.loadList(0, true)
|
||
},
|
||
switchTab(index) {
|
||
this.currentTab = index
|
||
// xuchao: 首次进入该 Tab 才拉首页;已有数据保留各自游标继续分页
|
||
if (this.listOf(index).length === 0 && !this.pager[index].loading) {
|
||
this.loadList(index, true)
|
||
}
|
||
},
|
||
onSearch() {
|
||
this.loadList(0, true)
|
||
},
|
||
// xuchao: 换筛选条件即回第 1 页重查(关键词保持不变,两者为 AND)
|
||
onFilterChange(e) {
|
||
this.districtId = e.districtId
|
||
this.category = e.category
|
||
this.loadList(0, true)
|
||
},
|
||
goDetail(item) {
|
||
uni.navigateTo({
|
||
url: '/pages/rights/detail?id=' + item.ruleId
|
||
})
|
||
},
|
||
goBack() {
|
||
// xuchao: 直接通过链接进入(无历史栈)时回到首页
|
||
const pages = getCurrentPages()
|
||
if (pages.length > 1) {
|
||
uni.navigateBack()
|
||
} else {
|
||
uni.reLaunch({ url: '/pages/index/index' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
page {
|
||
background-color: #f2f2f7;
|
||
}
|
||
</style>
|
||
<style scoped lang="less">
|
||
.page-auth {
|
||
background-color: #ffffff;
|
||
}
|
||
/* 认证表单 */
|
||
.auth-container {
|
||
min-height: 100vh;
|
||
padding: 16px;
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.title-bar {
|
||
width: 4px;
|
||
height: 16px;
|
||
background-color: #0088ff;
|
||
border-radius: 2px;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.title-text {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1a1818;
|
||
}
|
||
|
||
.form-area {
|
||
background-color: #ffffff;
|
||
border-radius: 8px;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.form-item {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 64px;
|
||
width: 100%;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
position: relative;
|
||
}
|
||
|
||
.form-label {
|
||
font-size: 14px;
|
||
color: #333333;
|
||
width: 80px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.form-label.required::before {
|
||
content: '*';
|
||
color: #ff4d4f;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.form-input {
|
||
flex: 1;
|
||
font-size: 14px;
|
||
color: #333333;
|
||
}
|
||
|
||
.form-value {
|
||
flex: 1;
|
||
font-size: 14px;
|
||
color: #333333;
|
||
}
|
||
|
||
.phone-value {
|
||
color: #999999;
|
||
}
|
||
|
||
.form-arrow {
|
||
font-size: 14px;
|
||
color: #cccccc;
|
||
}
|
||
|
||
.auth-btn {
|
||
margin: 24px 0;
|
||
background-color: #0088ff;
|
||
color: #ffffff;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
height: 48px;
|
||
line-height: 48px;
|
||
border-radius: 24px;
|
||
}
|
||
|
||
.tips-section {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.tips-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.tip-item {
|
||
font-size: 13px;
|
||
color: #ff4d4f;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 未中奖空状态 */
|
||
.empty-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding-top: 160px;
|
||
}
|
||
|
||
.empty-img {
|
||
width: 200px;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 18px;
|
||
color: #999999;
|
||
margin-top: 24px;
|
||
}
|
||
|
||
/* 权益信息 - 蓝色横幅 */
|
||
.banner-area {
|
||
background-color: #0088ff;
|
||
padding: 80px 16px 96px;
|
||
border-radius: 0 0 50px 50px;
|
||
}
|
||
|
||
.banner-title {
|
||
font-size: 24px;
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
display: block;
|
||
text-align: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.banner-expire {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.expire-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
background-color: #ffffff;
|
||
color: #0088ff;
|
||
border-radius: 50%;
|
||
text-align: center;
|
||
line-height: 18px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.expire-text {
|
||
font-size: 24px;
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.expire-label {
|
||
font-size: 24px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-card {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #FFFFFF;
|
||
margin: -60px 12px 0;
|
||
border-radius: 10px;
|
||
padding: 16px 24px;
|
||
box-shadow: 0px 4px 4px 0px #00000019;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.stats-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.stats-label {
|
||
font-size: 12px;
|
||
color: #999999;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stats-value-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.stats-currency {
|
||
font-size: 14px;
|
||
color: #1a1818;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stats-number {
|
||
font-size: 28px;
|
||
color: #1a1818;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.stats-number-large {
|
||
font-size: 28px;
|
||
color: #1a1818;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.stats-unit {
|
||
font-size: 12px;
|
||
color: #999999;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.stats-divider {
|
||
width: 1px;
|
||
height: 36px;
|
||
background-color: #eeeeee;
|
||
}
|
||
|
||
/* xuchao: Tab + 搜索 + 筛选条一起吸顶。padding-top 替代原 tab-bar 的 margin-top,
|
||
否则吸顶后顶部会留一条能透出列表内容的缝隙;z-index 需高于筛选遮罩(90) */
|
||
.info-sticky {
|
||
background-color: #ffffff;
|
||
padding-top: 12px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
/* Tab 切换 */
|
||
.tab-bar {
|
||
display: flex;
|
||
background-color: #ffffff;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.tab-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 12px 0;
|
||
position: relative;
|
||
}
|
||
|
||
.tab-item.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 24px;
|
||
height: 3px;
|
||
background-color: #0088ff;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.tab-text {
|
||
font-size: 14px;
|
||
color: #999999;
|
||
}
|
||
|
||
.tab-item.active .tab-text {
|
||
color: #0088ff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 搜索栏 */
|
||
.search-area {
|
||
/* xuchao: 底部留白交给筛选条的 20rpx 上内边距,保证三段间距一致 */
|
||
padding: 12px 12px 0;
|
||
}
|
||
|
||
.search-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 36px;
|
||
background-color: #ffffff;
|
||
/* xuchao: 头部改白底后,白色搜索框在白色吸顶栏上必须描边才分得出来;
|
||
显式 border-box 保证加边框后高度仍是 36px */
|
||
border: 1px solid #e5e5ea;
|
||
box-sizing: border-box;
|
||
border-radius: 18px;
|
||
padding: 0 12px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
font-size: 14px;
|
||
color: #333333;
|
||
}
|
||
|
||
/* 权益列表 */
|
||
/* xuchao: 原 scroll-view 下 gap 不生效(卡片被包在 scroll-view-content 内),
|
||
改普通容器后去掉 gap,卡片间距仍由 .rights-card 的 margin-bottom 提供,视觉不变 */
|
||
.list-area {
|
||
width: 750rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* xuchao: 「待使用」列表顶部 24rpx(=12px) 灰底间隔,与卡片之间的间距一致,
|
||
也与筛选条和列表之间的 12px 对齐;只加在这个 Tab,不影响「已预约/已使用」 */
|
||
.list-area--todo {
|
||
padding-top: 24rpx;
|
||
}
|
||
|
||
.nomore-area {
|
||
width: 100%;
|
||
padding-bottom: 24px;
|
||
}
|
||
|
||
.rights-card {
|
||
width: 702rpx;
|
||
background-color: #fff;
|
||
margin-left: 24rpx;
|
||
padding: 24rpx;
|
||
display: flex;
|
||
position: relative;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.card-thumb {
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* xuchao: image 由 thumb 裁圆角,自身不再设圆角 */
|
||
.card-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.card-area {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
display: block;
|
||
max-width: 100%;
|
||
padding: 1px 5px;
|
||
border-radius: 0 0 6px 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
color: #ffffff;
|
||
font-size: 10px;
|
||
line-height: 14px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-info {
|
||
width: 490rpx;
|
||
margin-left: 14rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 150rpx;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #1a1818;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-category {
|
||
font-size: 13px;
|
||
color: #999999;
|
||
margin-top: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-address {
|
||
width: 300rpx;
|
||
font-size: 13px;
|
||
color: #999999;
|
||
margin-top: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-distance {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
position: absolute;
|
||
bottom: 24rpx;
|
||
right: 24rpx;
|
||
}
|
||
|
||
.distance-text {
|
||
font-size: 12px;
|
||
color: #999999;
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding-top: 80px;
|
||
}
|
||
|
||
.empty-state-img {
|
||
width: 200px;
|
||
}
|
||
|
||
.empty-state-text {
|
||
font-size: 16px;
|
||
color: #999999;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* 返回按钮 */
|
||
.back-btn {
|
||
position: fixed;
|
||
top: 24px;
|
||
left: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.back-text {
|
||
font-size: 14px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
/* xuchao: noWin 状态(无中奖空态页)返回按钮改黑色 */
|
||
.back-btn--black .back-text {
|
||
color: #000000;
|
||
}
|
||
|
||
/* xuchao: 顶部返回导航条(auth状态) */
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 56px;
|
||
background-color: #ffffff;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
position: relative;
|
||
}
|
||
|
||
.nav-back {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.nav-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: #1a1818;
|
||
padding: 0 56px;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
</style> |