Merge branch 'master' into xuchao

This commit is contained in:
jacobxu666
2026-09-03 16:29:20 +08:00
15 changed files with 805 additions and 190 deletions
+1
View File
@@ -1,3 +1,4 @@
unpackage/
node_modules/
.DS_Store
.playwright-mcp/
+9
View File
@@ -14,4 +14,13 @@
<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "uview-ui/index.scss";
/* xuchao: 全站统一字体栈:系统字体优先,中文回退顺序兼顾 iOS/鸿蒙/Android/Windows */
page,
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'HarmonyOS Sans SC',
'Microsoft YaHei', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Hiragino Sans GB',
Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
# CLAUDE.md
## 项目概览
怀化人社 H5(hhrs)—— 基于 uni-app 构建的移动端 H5 前端,入口 `pages/`,UI 依赖 `uview-ui`。属于 12301-dscp 项目群中的「怀化人社」H5 端。
## Git 规范
- 独立 git 仓库,远程:`https://git.hn12301.net/xuchao/hhrs.git`,主分支 `master`
- 提交信息使用中文
- `.gitignore` 已忽略 `unpackage/`、`node_modules/`、`.DS_Store`;**不要**提交构建产物(unpackage 目录)
## 部署信息
线上为纯静态站点,位于 121.43.239.220(阿里云 ECS / CentOS 7,宿主机 Nginx 直接伺服):
| 项目 | 内容 |
| --- | --- |
| 站点 | https://hh.hn12301.net/ |
| 网站目录 | `/home/hh/html`(uni-app 构建产物,入口 index.html) |
| Nginx 配置 | `/etc/nginx/conf.d/h5-site-https.conf`(80 catch-all → 301 跳 HTTPS) |
| 证书 | Let's Encrypt ec-256,acme.sh 每月 1/7/13/19 日 01:01 自动续期,手动续期见运维手册 |
**发布流程**:本地 HBuilderX 构建 → 覆盖上传产物到 `/home/hh/html/` → 静态文件即时生效,**无需重启 Nginx**。改过 Nginx 配置才需要 `nginx -t && nginx -s reload`。
**关键注意**:
- 前端 API 基地址指向 `https://dscp-uat.hn12301.net`(UAT 测试环境),**后端不在部署机上**。出现"页面能打开但数据不通"时优先排查 dscp-uat.hn12301.net 的接口
- 页面图片走 OSS:`aliyunoss.hn12301.net/hhrs/`
- SSH 凭据及全部运维细节见 Obsidian 工作资料《121.43.239.220(H5站点服务器)运维手册_2026-08-04.md》
- 数据录入(商铺/资源/中奖等)操作指南见仓库根 docs 的 `hhrs-ops-guide.md`(运营向)
+4
View File
@@ -20,12 +20,15 @@ let StoreDetailUrl = 'mp-api/hhrsStore/detail';
let WinnerDatesUrl = 'mp-api/hhrsWinner/dates';
let WinnerListUrl = 'mp-api/hhrsWinner/list';
let AppointUrl = 'zhy-api/cardBag/appoint';
let TokenDecodeUrl = 'open-api/hhService/tokenDecode';
// 此处第二个参数vm,就是我们在页面使用的this
const install = (Vue, vm) => {
let checkerLogin = (params = {}) => vm.$u.post(LoginUrl, params);
let sendLoginVerifyCode = (params = {}) => vm.$u.post(SendVerifyCodeUrl, params);
// xuchao: 云闪付入口 token 解码
let tokenDecode = (params = {}) => vm.$u.post(TokenDecodeUrl, params);
// 文旅场馆
let getVenueList = (params = {}) => vm.$u.get(VenueListUrl, params);
@@ -57,6 +60,7 @@ const install = (Vue, vm) => {
vm.$u.api = {
checkerLogin,
sendLoginVerifyCode,
tokenDecode,
getVenueList,
getVenueDetail,
getMerchantList,
+1 -1
View File
@@ -2,7 +2,7 @@
// 同时,我们也可以在此使用getApp().globalData,如果你把token放在getApp().globalData的话,也是可以使用的
const install = (Vue, vm) => {
Vue.prototype.$u.http.setConfig({
baseUrl: 'https://dscp-dev.hn12301.net',
baseUrl: 'https://dscp-uat.hn12301.net',
// loadingText: '努力加载中~',
// loadingTime: 18800,
// 如果将此值设置为true,拦截回调中将会返回服务端返回的所有数据response,而不是response.data
+115 -7
View File
@@ -1,14 +1,100 @@
<template>
<view class="page">
<view class="content">
<text class="heading">隐私协议</text>
<text class="body">请在此处填写隐私协议的具体内容。</text>
<text class="heading">怀化社保卡惠民活动隐私协议</text>
<text class="meta">生效日期:2026年9月1日</text>
<view class="section" v-for="(s, i) in sections" :key="i">
<text class="section-title">{{ s.h }}</text>
<text class="para" v-for="(p, j) in s.p" :key="j">{{ p }}</text>
</view>
</view>
</template>
<script>
export default {}
export default {
data() {
return {
sections: [
{
h: '一、引言',
p: [
'怀化社保卡惠民活动运营方(以下简称"我们")深知个人信息对您的重要性,并会严格按照《中华人民共和国个人信息保护法》《中华人民共和国网络安全法》等法律法规的要求,采取相应的安全保护措施,保护您的个人信息安全。',
'本隐私协议旨在向您说明本平台(网址:hh.hn12301.net,以下简称"本平台")如何收集、使用、存储、共享和保护您的个人信息,以及您如何管理自己的信息。请您在使用本平台服务前,仔细阅读并充分理解本隐私协议,特别是以粗体标识的涉及敏感个人信息的条款。您勾选同意并开始使用服务,即表示我们将按照本隐私协议处理您的个人信息。'
]
},
{
h: '二、我们收集的个人信息',
p: [
'2.1 为向您提供本平台各项服务,我们遵循合法、正当、必要的原则收集以下信息:',
'(1)注册与登录信息:您的手机号码及短信验证码,用于身份验证和创建平台登录状态。您通过云闪付App内活动入口进入的,我们会从该渠道的授权凭证中解析您的手机号、姓名、证件号码,用于免登录识别您的活动资格。',
'(2)实名认证信息:您申请权益包资格认证时填写的真实姓名、证件类型、证件号码。该类信息属于敏感个人信息,仅用于核验您的社保卡持卡人身份及活动参与资格。',
'(3)人脸信息:实名认证过程中,您需通过第三方身份核验服务完成活体人脸核验。人脸信息属于敏感个人信息,由核验服务商依据其服务规范采集与比对,我们仅接收核验结果(通过/未通过),不存储您的面部图像或人脸特征数据。拒绝进行人脸核验的,将无法完成实名认证并领取权益包,但不影响您浏览场馆、商户等公开信息。',
'(4)位置信息:为向您展示文旅场馆、商户、权益门店与您的距离并按就近原则排序,我们会经您的设备/浏览器授权后收集您的粗略地理位置(经纬度)。您可以在浏览器定位权限中随时关闭授权;关闭后您仍可正常使用平台,仅距离展示功能不可用。',
'(5)交易与服务记录:您预约、使用权益产生的预约记录和核销记录,用于向您展示权益状态和提供客服协助。',
'(6)设备与日志信息:为保障网络与活动安全,我们的服务器会自动记录访问日志,包括IP地址、访问日期与时间、浏览器类型等。'
]
},
{
h: '三、我们如何使用个人信息',
p: [
'3.1 我们将收集的个人信息用于以下目的:(1)验证用户身份,提供登录服务;(2)核验社保卡持卡人活动参与资格,发放、管理权益包;(3)处理权益的预约与核销;(4)计算并展示场馆、商户与您的距离;(5)依法开展中奖名单公示、活动审计与监管报送;(6)保障平台与账号安全,防范作弊、冒用等风险;(7)响应您的咨询与客服请求。',
'3.2 中奖名单公示:依据活动规则,参与活动结果需向公众公示。公示内容中的姓名、证件号码、手机号码均经脱敏处理(如隐藏部分字符)后展示,我们不会以公示方式披露您的完整个人身份信息。'
]
},
{
h: '四、个人信息的共享、转让与公开披露',
p: [
'4.1 共享:除以下情形外,我们不会与任何公司、组织和个人共享您的个人信息:(1)获取您的单独同意后共享;(2)与完成服务所必需的第三方服务商共享:短信发送服务商(共享手机号用于发送验证码)、第三方人脸/身份核验服务商(共享姓名、证件号码、人脸影像用于核验证照真伪)、云闪付服务提供方(依据您的授权回传必要信息用于资格识别)、为权益核销提供支持的场馆及商户(在您使用对应权益时共享核销所必需的权益凭证信息);(3)根据法律法规、行政或司法机关的要求提供;(4)向人力资源社会保障等相关主管部门报送活动资格核验及监管所需的数据。',
'4.2 转让:我们不会将您的个人信息转让给任何公司、组织和个人,但涉及合并、收购或破产清算时,如涉及个人信息转让,我们会要求新的持有方继续受本隐私协议约束,否则将重新征得您的授权同意。',
'4.3 公开披露:我们仅会对中奖名单按第3.2条所述进行脱敏公示,或在获得您明确同意或基于法律强制性要求时公开披露您的个人信息。'
]
},
{
h: '五、个人信息的存储',
p: [
'5.1 存储地点:我们在中华人民共和国境内运营中收集和产生的个人信息,存储于境内服务器,不会向境外传输。',
'5.2 存储期限:我们仅在实现本隐私协议所述目的所必需的最短期限内保留您的个人信息,法律法规另有规定的除外。活动结束后,超出留存期限的个人信息我们将予以删除或匿名化处理。登录状态凭证保存在您的设备本地,清除浏览器存储即可失效。',
'5.3 如我们停止运营本平台,我们将及时停止收集您个人信息的活动,并将该终止运营的情况在本平台进行公告。'
]
},
{
h: '六、我们如何保护个人信息',
p: [
'6.1 我们采用传输层加密(HTTPS)、访问权限控制、数据脱敏展示等业界通行的安全技术措施和管理制度,防止您的个人信息遭到未经授权的访问、公开披露、使用、修改、损坏或丢失。',
'6.2 我们仅允许有必要知晓这些信息的员工、合作伙伴在权限范围内访问个人信息,并要求其履行相应的保密义务。',
'6.3 若不幸发生个人信息安全事件,我们将按照法律法规的要求,及时向您告知事件基本情况、可能的影响、我们已采取的处置措施等,并向有关主管部门报告。'
]
},
{
h: '七、您的权利',
p: [
'7.1 查阅与更正:您可以在本平台"我的"等页面查阅您的登录手机号、权益状态、预约与核销记录。如发现信息有误,或需要更新您的个人信息,可通过本隐私协议第十条所载方式联系我们更正。',
'7.2 删除:有下列情形的,您可以通过联系我们请求删除您的个人信息:(1)我们违反法律、行政法规规定收集、使用个人信息的;(2)我们未征得您的同意收集、使用个人信息的;(3)您不再使用本平台服务,或我们终止了服务或活动的;(4)我们违反约定处理您的个人信息的。法律法规规定的存储期限届满前或存在法定留存事由的,我们可能无法立即响应您的删除请求,届时将向您说明原因。',
'7.3 撤回同意:您可以通过设备/浏览器设置关闭地理位置授权、清除本地存储的登录凭证等方式撤回相应授权。撤回同意不影响撤回前基于同意已进行的信息处理活动。',
'7.4 注销账号:您可以通过联系客服申请注销您的账号。注销后,我们将删除或匿名化处理您的个人信息,法律法规要求留存的除外。'
]
},
{
h: '八、未成年人保护',
p: [
'8.1 本平台面向具备完全民事行为能力的成年人提供服务。若您为未满18周岁的未成年人,请在监护人的陪同和同意下使用本平台并接受相关权益服务。我们重视对未成年人个人信息的保护,如监护人发现我们在未获取监护人同意的情况下收集了未成年人的个人信息,可通过客服联系我们,我们将尽快删除相关数据。'
]
},
{
h: '九、本隐私协议的更新',
p: [
'9.1 我们的服务及业务情况可能不断变化。如该等变化导致本隐私协议所述处理目的、处理方式、个人信息种类或保存期限发生变更的,我们将以页面公告等显著方式通知您,并在必要时重新征得您的同意。'
]
},
{
h: '十、如何联系我们',
p: [
'如您对本隐私协议或个人信息处理有任何疑问、意见或投诉,可通过本平台"我的—联系客服"入口,或拨打客服电话 0731-88912301 与我们联系,我们将在收到您的反馈后的15个工作日内予以答复。'
]
}
]
}
}
}
</script>
<style>
@@ -21,15 +107,37 @@ export default {}
.heading {
font-size: 18px;
font-weight: bold;
font-weight: 600;
color: #333333;
display: block;
margin-bottom: 16px;
margin-bottom: 8px;
}
.body {
.meta {
font-size: 12px;
color: #999999;
display: block;
margin-bottom: 20px;
}
.section {
display: block;
margin-bottom: 18px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #333333;
display: block;
margin-bottom: 8px;
}
.para {
font-size: 14px;
color: #666666;
line-height: 1.8;
display: block;
margin-bottom: 6px;
}
</style>
+111 -7
View File
@@ -1,14 +1,96 @@
<template>
<view class="page">
<view class="content">
<text class="heading">用户协议</text>
<text class="body">请在此处填写用户协议的具体内容。</text>
<text class="heading">怀化社保卡惠民活动用户协议</text>
<text class="meta">生效日期:2026年9月1日</text>
<view class="section" v-for="(s, i) in sections" :key="i">
<text class="section-title">{{ s.h }}</text>
<text class="para" v-for="(p, j) in s.p" :key="j">{{ p }}</text>
</view>
</view>
</template>
<script>
export default {}
export default {
data() {
return {
sections: [
{
h: '一、协议的范围与接受',
p: [
'1.1 本协议是您与怀化社保卡惠民活动运营方(以下简称"我们")之间,就您使用"怀化社保卡惠民活动"H5平台(网址:hh.hn12301.net,以下简称"本平台")及相关服务所订立的协议。',
'1.2 您点击"我已阅读并同意《用户协议》和《隐私协议》"、完成登录或以任何方式使用本平台服务的,即表示您已充分阅读、理解并接受本协议全部内容。如您不同意,请立即停止使用。',
'1.3 本协议内容同时包括本平台已经发布的或将来可能发布并采取合理途径公示的各类活动规则、须知、公告,一经生效即构成本协议的组成部分。'
]
},
{
h: '二、服务说明',
p: [
'2.1 本平台是怀化社保卡惠民活动的线上服务入口,主要提供以下服务:(1)文旅场馆信息与优惠内容查询;(2)合作商户及折扣信息查询;(3)社保卡权益包的资格认证、权益领取、预约与核销记录查询;(4)活动中奖名单公示查询;(5)活动参加指南与社保卡使用指引等信息展示。',
'2.2 您可以通过手机浏览器或云闪付App内的活动入口访问本平台。云闪付入口下,您的登录手机号等信息以该渠道传递的授权凭证为准。',
'2.3 本平台展示的文娱场馆、商户的折扣内容、服务价格、地址等信息,由对应场馆、商户提供并对真实性负责,平台仅作信息展示。'
]
},
{
h: '三、账号与登录',
p: [
'3.1 本平台使用真实手机号并通过短信验证码方式登录。您应使用本人实名登记的手机号,并对账号项下的一切操作负责。',
'3.2 您的登录凭证(token)在设备本地保存以维持登录状态。请妥善保管您的手机与登录状态,勿将手机出借他人或向他人泄露验证码;因您保管不善造成的损失由您自行承担。',
'3.3 若您发现账号被冒用或验证码被他人获取,请立即通过平台客服渠道反馈。'
]
},
{
h: '四、权益包资格认证',
p: [
'4.1 权益包服务面向符合活动条件的怀化市社保卡持卡人员。申请认证时,您须填写真实姓名、证件类型及证件号码,并按流程完成人脸核验。',
'4.2 您承诺所提交的身份信息真实、准确、完整且为您本人所有。以虚假、冒用他人信息等方式通过认证的,我们有权取消认证结果、收回已发放权益,并依法追究相应责任。',
'4.3 认证结果、权益领取资格、权益状态以平台系统核定为准。权益包存在有效期限,到期未使用的权益自动失效,不予折现或补发,活动规则另有说明的除外。',
'4.4 权益的预约、核销须按对应商户的使用规则在有效期内操作;到店消费时请按商户要求出示相应凭证并配合核销。'
]
},
{
h: '五、用户行为规范',
p: [
'5.1 您不得实施下列行为:(1)伪造、冒用他人身份信息参与活动或认证;(2)通过机器脚本、群控设备等技术手段批量注册、恶意抢占预约名额或套取、倒卖权益;(3)干扰、破坏本平台正常运营秩序,或对平台系统进行攻击、破解;(4)利用平台发布、传播违法违规信息;(5)其他违反法律法规或活动规则的行为。',
'5.2 若您存在上述行为,我们有权视情节采取限制或取消活动资格、收回权益、中止或终止服务、封禁账号等措施,并保留追究法律责任的权利。'
]
},
{
h: '六、活动的变更、中止与终止',
p: [
'6.1 因活动安排、政策调整、不可抗力、系统故障、网络或安全问题等原因,本平台可能调整、中止或终止部分或全部服务,我们将尽可能提前公告或以适当方式通知您。',
'6.2 本平台提供的内容与服务"按现状"提供。因下列原因导致服务中断、数据丢失或您未能正常享受权益的,我们不承担赔偿责任,但将尽力减少由此给您造成的损失:(1)不可抗力;(2)基础运营商或第三方服务(短信通道、云资源、身份核验渠道等)的故障;(3)您自身网络、设备或操作原因。',
'6.3 您在使用本平台过程中所联系的场馆、商户等第三方,其与本平台之间为独立民事主体关系。您在第三方处接受服务发生的消费纠纷,由您与该第三方依据法律法规及消费约定解决,平台将视情况提供必要的信息协助。'
]
},
{
h: '七、知识产权',
p: [
'7.1 本平台的页面设计、文字、图片、标识、软件等内容的知识产权归我们或相关权利人所有,未经许可,您不得复制、修改、翻译、镜像、出租、出售或以其他商业方式使用。'
]
},
{
h: '八、协议的变更',
p: [
'8.1 我们可能在活动时间、政策、服务功能发生变化时修订本协议,修订后的协议将在本页面公布并标注新的生效日期。自公布之日起您继续使用本平台服务的,视为接受修订后的协议;不同意的,应停止使用并可申请注销账号。'
]
},
{
h: '九、法律适用与争议解决',
p: [
'9.1 本协议的订立、执行与解释均适用中华人民共和国法律。',
'9.2 因本协议或服务产生的争议,双方应友好协商解决;协商不成的,任何一方可向我们所在地有管辖权的人民法院提起诉讼。'
]
},
{
h: '十、联系我们',
p: [
'如您对本协议或活动服务有任何疑问、意见或建议,可通过本平台"我的—联系客服"入口,或拨打客服电话 0731-88912301 与我们联系。'
]
}
]
}
}
}
</script>
<style>
@@ -21,15 +103,37 @@ export default {}
.heading {
font-size: 18px;
font-weight: bold;
font-weight: 600;
color: #333333;
display: block;
margin-bottom: 16px;
margin-bottom: 8px;
}
.body {
.meta {
font-size: 12px;
color: #999999;
display: block;
margin-bottom: 20px;
}
.section {
display: block;
margin-bottom: 18px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #333333;
display: block;
margin-bottom: 8px;
}
.para {
font-size: 14px;
color: #666666;
line-height: 1.8;
display: block;
margin-bottom: 6px;
}
</style>
+51 -22
View File
@@ -16,13 +16,14 @@
</view>
<!-- 商户列表 -->
<scroll-view class="list-area" scroll-y @scrolltolower="loadMore" :lower-threshold="100">
<view class="list-area">
<view class="venue-card" v-for="item in list" :key="item.shopId" @click="goDetail(item)">
<image class="card-img" :src="item.image" mode="aspectFill" />
<view class="card-info">
<text class="card-name">{{ item.name }}</text>
<text class="card-category">{{ item.category }}</text>
<view class="card-bottom">
<!-- xuchao: 地址与距离同属一个模块 -->
<view class="card-address-row">
<text class="card-address">{{ item.address }}</text>
<view class="card-distance" v-if="item.distance !== null">
<u-icon name="map" color="#999999" size="24"></u-icon>
@@ -32,9 +33,9 @@
</view>
</view>
<view v-if="hasScrolled" class="nomore-area">
<u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="''" :nomore-text="'我也是有底线的'" />
<u-loadmore :status="loadStatus" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
</view>
</scroll-view>
</view>
</view>
</template>
@@ -51,22 +52,35 @@ export default {
lng: '',
lat: '',
loadStatus: 'loadmore',
hasScrolled: false
hasScrolled: false,
reqSeq: 0
}
},
onLoad() {
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
const hasLoc = uni.getStorageSync('lng') && uni.getStorageSync('lat')
if (hasLoc) {
this.lng = uni.getStorageSync('lng')
this.lat = uni.getStorageSync('lat')
this.loadList()
} else {
// xuchao: 定位仅在首页触发过,直接进本页无缓存时主动获取一次,保证距离列可展示
import('@/common/location.js').then(module => module.default.getLocation())
.then(({ lng, lat }) => {
this.lng = lng
this.lat = lat
})
.catch(() => {})
.finally(() => this.loadList())
}
this.loadList()
},
// xuchao: 改用页面级触底加载(scroll-view 无固定高度时 scrolltolower 永不触发)
onReachBottom() {
this.loadMore()
},
methods: {
loadList() {
if (this.loading) return
// xuchao: 请求序号 —— 新请求直接取代在途请求,过期响应整体丢弃(修复搜索竞态,故不再用 loading 提前 return)
const seq = ++this.reqSeq
this.loading = true
this.loadStatus = 'loading'
const params = {
@@ -78,6 +92,7 @@ export default {
if (this.lng) params.lng = this.lng
if (this.lat) params.lat = this.lat
this.$u.api.getStoreList(params).then(res => {
if (seq !== this.reqSeq) return
if (res.errcode === 0 && res.data) {
const list = res.data.list || []
if (this.pageNo === 1) {
@@ -86,8 +101,11 @@ export default {
this.list = this.list.concat(list)
}
this.total = res.data.total || 0
// xuchao: 本页不足一页即已到尾部,钉住 total,防止 total 中途变大时空翻
if (list.length < this.pageSize) this.total = this.list.length
}
}).finally(() => {
if (seq !== this.reqSeq) return
this.loading = false
if (this.list.length === 0) {
this.loadStatus = 'nomore'
@@ -106,7 +124,13 @@ export default {
}
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
},
goDetail(item) {
uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId })
@@ -180,18 +204,23 @@ export default {
}
.list-area {
padding: 12px;
// xuchao: border-box 防止 padding 撑出页面横向滚动
box-sizing: border-box;
}
.venue-card {
display: flex;
align-items: flex-end;
box-sizing: border-box;
background-color: #ffffff;
border: 1px solid #f2f2f7;
border-radius: 6px;
padding: 12px;
width: 702rpx;
padding: 24px 12px;
gap: 12px;
box-shadow: 0 2px 3px #f2f2f7;
margin: 12px;
margin-bottom: 12px;
}
.card-img {
@@ -203,11 +232,11 @@ export default {
.card-info {
flex: 1;
// xuchao: min-width:0 允许 flex 子项收缩到内容宽度以下,配合 nowrap 文本出省略号
min-width: 0;
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
min-width: 0;
}
.card-name {
@@ -226,22 +255,22 @@ export default {
margin-top: 8px;
}
.card-bottom {
.card-address-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 8px;
gap: 8px;
width: 100%;
margin-top: 8px;
}
.card-address {
flex: 1;
min-width: 0;
font-size: 14px;
color: #727272;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.card-distance {
+52 -5
View File
@@ -59,10 +59,56 @@
]
}
},
onLoad() {
onLoad(options) {
if (options.token) {
this.handleTokenLogin(options.token)
}
this.getLocation()
},
methods: {
// xuchao: 云闪付入口 token 自动登录
handleTokenLogin(token) {
this.$u.api.tokenDecode({ token }).then(res => {
if (res.errcode === 0 && res.data && res.data.mobile) {
uni.setStorageSync('phone', res.data.mobile)
this.$u.api.getEligibility({ phone: res.data.mobile, identityNo: res.data.idNo || '', name: res.data.name || '' }).then(eRes => {
if (eRes.errcode === 0) {
uni.setStorageSync('isAuth', eRes.data.isAuth)
uni.setStorageSync('hasWin', eRes.data.hasWin)
uni.setStorageSync('cardNumber', eRes.data.cardNumber)
uni.setStorageSync('expiresTime', eRes.data.expiresTime)
}
})
// xuchao: 清除地址栏 token,防止分享泄露
this.clearUrlToken()
} else {
this.degradeToPhoneLogin()
}
}).catch(() => {
this.degradeToPhoneLogin()
})
},
// xuchao: 解码失败兜底,提示并跳手机号登录
degradeToPhoneLogin() {
uni.showToast({ title: '登录凭证已失效,请使用手机号登录', icon: 'none' })
setTimeout(() => {
uni.navigateTo({ url: '/pages/index/login' })
}, 800)
},
// xuchao: 从 URL hash 中移除 token 参数
clearUrlToken() {
try {
const hash = window.location.hash || ''
const qIndex = hash.indexOf('?')
if (qIndex === -1) return
const params = new URLSearchParams(hash.substring(qIndex + 1))
if (!params.has('token')) return
params.delete('token')
const rest = params.toString()
const base = hash.substring(0, qIndex)
window.history.replaceState(null, '', rest ? `${base}?${rest}` : base)
} catch (e) {}
},
getLocation() {
import('@/common/location.js').then(module => {
module.default.getLocation().then(({ lng, lat }) => {
@@ -83,8 +129,10 @@
}
const isAuth = uni.getStorageSync('isAuth')
const hasWin = uni.getStorageSync('hasWin')
// xuchao: 最近发起过认证流程(含取消)时忽略旧认证缓存,一律回认证页
const authFlowStarted = uni.getStorageSync('authFlowStarted')
let status = 'auth'
if (isAuth) {
if (isAuth && !authFlowStarted) {
status = hasWin ? 'info' : 'noWin'
}
uni.navigateTo({ url: `/pages/rights/rights?status=${status}` })
@@ -144,7 +192,6 @@
}
.king-text {
font-family: 'Source Han Sans SC', 'Source Han Sans CN', 'Noto Sans CJK SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 24rpx;
color: #333333;
text-align: center;
@@ -177,7 +224,7 @@
.title-text {
font-size: 14px;
font-weight: bold;
font-weight: 600;
color: #333333;
}
@@ -216,7 +263,7 @@
.guide-title {
font-size: 16px;
font-weight: bold;
font-weight: 600;
color: #0088FF;
margin-bottom: 10rpx;
}
+39 -21
View File
@@ -1,5 +1,12 @@
<template>
<view class="page">
<!-- xuchao: 顶部导航(返回+页面名称) -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#1a1818" size="40"></u-icon>
</view>
<text class="nav-title">手机号登录</text>
</view>
<!-- 标题区 -->
<view class="title-area">
<text class="title">手机号登录</text>
@@ -32,12 +39,6 @@
<text class="agreement-text" @click="agreed = !agreed">和</text>
<text class="agreement-link" @click.stop="goAgreement('privacy')">《隐私协议》</text>
</view>
<!-- 返回按钮 -->
<view class="back-btn" @click="goBack">
<image class="back-icon" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_back.png" mode="aspectFit"></image>
<text class="back-text">返回上一页</text>
</view>
</view>
</template>
@@ -135,7 +136,13 @@ export default {
uni.navigateTo({ url })
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页,避免返回无响应
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}
}
}
@@ -159,7 +166,7 @@ export default {
.title {
font-size: 44rpx;
font-weight: bold;
font-weight: 700;
color: #1a1a1a;
display: block;
text-align: center;
@@ -264,24 +271,35 @@ export default {
color: #0088FF;
}
/* 返回按钮 */
.back-btn {
position: fixed;
left: 24px;
bottom: 50px;
/* xuchao: 顶部返回导航条(负外边距抵消页面左右 padding,通栏展示) */
.nav-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
height: 56px;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
position: relative;
margin: 0 -40rpx;
}
.back-icon {
width: 44px;
height: 44px;
.nav-back {
position: absolute;
left: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
}
.back-text {
font-size: 12px;
color: #666666;
.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>
+33 -22
View File
@@ -1,22 +1,23 @@
<template>
<view class="page">
<view v-if="detail">
<!-- 门店信息卡片 -->
<!-- 门店信息卡片(信息+地址合并容器) -->
<view class="store-card">
<image class="store-img" :src="detail.shopImage" mode="aspectFill" />
<view class="store-info">
<text class="store-name">{{ detail.shopName }}</text>
<text class="store-category">{{ detail.category }}</text>
<text class="store-hours">营业时间:{{ detail.businessTime }}</text>
<view class="store-main">
<image class="store-img" :src="detail.shopImage" mode="aspectFill" />
<view class="store-info">
<text class="store-name">{{ detail.shopName }}</text>
<text class="store-category">{{ detail.category }}</text>
<text class="store-hours">营业时间:{{ detail.businessTime }}</text>
</view>
</view>
</view>
<!-- 地址行 -->
<view class="address-row">
<text class="address-text">{{ detail.address }}</text>
<view class="distance-row" v-if="detail.distance !== null">
<u-icon name="map" color="#999999" size="24"></u-icon>
<text class="distance-text">{{ detail.distance < 1 ? Math.round(detail.distance * 1000) + 'm' : Math.round(detail.distance) + 'km' }}</text>
<view class="divider"></view>
<view class="address-row">
<text class="address-text">{{ detail.address }}</text>
<view class="distance-row" v-if="detail.distance !== null">
<u-icon name="map" color="#999999" size="24"></u-icon>
<text class="distance-text">{{ detail.distance < 1 ? Math.round(detail.distance * 1000) + 'm' : Math.round(detail.distance) + 'km' }}</text>
</view>
</view>
</view>
@@ -180,12 +181,17 @@ export default {
}
</style>
<style scoped lang="less">
/* 门店卡片 */
/* 门店卡片(信息+地址合并容器,样式与门店详情页一致) */
.store-card {
display: flex;
background-color: #ffffff;
margin:0rpx 12px 12px 12px;
margin: 0 12px 12px 12px;
border-radius: 8px;
overflow: hidden;
}
.store-main {
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
@@ -201,8 +207,8 @@ export default {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
justify-content: flex-start;
gap: 8px;
}
.store-name {
@@ -221,13 +227,18 @@ export default {
color: #666666;
}
/* 地址行 */
.divider {
height: 1px;
background-color: #f2f2f7;
margin: 0 12px;
}
/* 地址行(卡片内) */
.address-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #ffffff;
padding: 12px 16px;
padding: 12px;
}
.address-text {
+123 -32
View File
@@ -1,14 +1,16 @@
<template>
<view class="page" :class="{ 'page-auth': status === 'auth' }">
<!-- 返回按钮(顶部,非auth状态) -->
<view class="back-btn" @click="goBack" v-if="status !== 'auth'">
<u-icon name="arrow-left" color="#ffffff" size="40"></u-icon>
<text class="back-text">返回</text>
<!-- 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-bottom" @click="goBack" v-if="status === 'auth'">
<image class="back-icon" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_back.png" mode="aspectFit"></image>
<text class="back-text-bottom">返回上一页</text>
<!-- 返回按钮(顶部,非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">
@@ -61,9 +63,9 @@
<view style="background: #fff;">
<view class="banner-area">
<text class="banner-title">恭喜您!获得怀化市社保卡权益包</text>
<view class="banner-expire">
<view class="banner-expire" v-if="expiresTime">
<text class="expire-icon">!</text>
<text class="expire-text">2026年12月31日</text>
<text class="expire-text">{{ expiresTime }}</text>
<text class="expire-label">到期</text>
</view>
</view>
@@ -74,7 +76,7 @@
<text class="stats-label">总权益价值</text>
<view class="stats-value-row">
<text class="stats-currency">¥</text>
<text class="stats-number">1500</text>
<text class="stats-number">{{ totalValueText }}</text>
<text class="stats-unit">元</text>
</view>
</view>
@@ -82,7 +84,7 @@
<view class="stats-item">
<text class="stats-label">可用权益数</text>
<view class="stats-value-row">
<text class="stats-number-large">10</text>
<text class="stats-number-large">{{ rightsCount }}</text>
<text class="stats-unit">个</text>
</view>
</view>
@@ -170,6 +172,10 @@
data() {
return {
status: 'auth',
// xuchao: 权益概览三字段,首屏用缓存兜底,随后由服务端复查刷新
expiresTime: uni.getStorageSync('expiresTime') || '',
rightsCount: uni.getStorageSync('rightsCount') || 0,
totalValueFen: uni.getStorageSync('totalValueFen') || 0,
form: {
realName: '',
phone: '',
@@ -197,6 +203,13 @@
checkLoading: false
}
},
computed: {
// xuchao: 总权益价值分转元,整除去小数,否则保留两位
totalValueText() {
const yuan = this.totalValueFen / 100
return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2)
}
},
onLoad(options) {
if (options.status) {
this.status = options.status
@@ -212,11 +225,64 @@
this.lat = loc.lat
}
this.checkAuthResult()
// xuchao: 权益状态会变化,缓存只作首屏展示,进入页面一律向服务端复查最新状态
this.refreshStatus()
if (this.status === 'info') {
this.loadRightsList()
}
},
methods: {
// 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')
@@ -257,10 +323,9 @@
}).then(eRes => {
console.log('eligibility res:', eRes)
if (eRes.errcode === 0) {
uni.setStorageSync('isAuth', eRes.data.isAuth)
uni.setStorageSync('hasWin', eRes.data.hasWin)
uni.setStorageSync('cardNumber', eRes.data.cardNumber)
uni.setStorageSync('expiresTime', eRes.data.expiresTime)
this.applyEligibility(eRes.data)
// xuchao: 认证成功,清除流程标记
uni.removeStorageSync('authFlowStarted')
if (eRes.data.isAuth) {
this.status = eRes.data.hasWin ? 'info' : 'noWin'
if (this.status === 'info') {
@@ -270,6 +335,8 @@
}
})
} else {
// xuchao: 实名未通过视为取消认证,清理缓存并标记
this.markAuthCanceled()
uni.showToast({
title: '认证未通过',
icon: 'none'
@@ -288,6 +355,8 @@
})
})
} else {
// xuchao: 未返回认证回调视为取消认证,清理缓存并标记
this.markAuthCanceled()
uni.showToast({
title: '认证未通过',
icon: 'none'
@@ -324,6 +393,8 @@
}
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 +
@@ -467,7 +538,13 @@
})
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}
}
}
@@ -614,7 +691,7 @@
.banner-title {
font-size: 24px;
color: #ffffff;
font-weight: 500;
font-weight: 700;
display: block;
text-align: center;
margin-bottom: 12px;
@@ -901,25 +978,39 @@
color: #ffffff;
}
/* 底部返回按钮 */
.back-btn-bottom {
position: fixed;
left: 24px;
bottom: 50px;
/* xuchao: noWin 状态(无中奖空态页)返回按钮改黑色 */
.back-btn--black .back-text {
color: #000000;
}
/* xuchao: 顶部返回导航条(auth状态) */
.nav-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 10;
height: 56px;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
position: relative;
}
.back-icon {
width: 44px;
height: 44px;
.nav-back {
position: absolute;
left: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
}
.back-text-bottom {
font-size: 12px;
color: #666666;
.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>
+132 -23
View File
@@ -1,5 +1,13 @@
<template>
<view class="page" v-if="store">
<view>
<view class="page" v-if="store">
<!-- xuchao: 顶部导航(返回+页面名称) -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#1a1818" size="40"></u-icon>
</view>
<text class="nav-title">{{ store.name }}</text>
</view>
<!-- 门店信息卡片(信息+地址合并容器) -->
<view class="store-card">
<view class="store-main">
@@ -20,6 +28,31 @@
</view>
</view>
<!-- 商户电话(取商铺客服电话字段,多个用,分隔,点击拨号) -->
<view class="discount-section" v-if="servicePhoneList.length">
<view class="section-title">
<u-icon name="phone-fill" color="#1296DB" size="34"></u-icon>
<text class="discount-title">商户电话</text>
</view>
<view class="discount-list">
<text class="discount-item">
<text v-for="(p, i) in servicePhoneList" :key="i" class="phone-link"
@click="callPhone(p)">{{ i > 0 ? ',' : '' }}{{ p }}</text>
</text>
</view>
</view>
<!-- 商品信息(内容取商铺门票信息字段) -->
<view class="discount-section" v-if="ticketInfoList.length">
<view class="section-title">
<image src="https://aliyunoss.hn12301.net/hhrs/hhrs_qysm.png" style="width: 30rpx;height: 30rpx;"></image>
<text class="discount-title">商品信息</text>
</view>
<view class="discount-list">
<text class="discount-item" v-for="(item, index) in ticketInfoList" :key="index">{{ item }}</text>
</view>
</view>
<!-- 商户优惠 -->
<view class="discount-section">
<view class="section-title">
@@ -30,11 +63,18 @@
<text class="discount-item" v-for="(item, index) in discountList" :key="index">{{ item }}</text>
</view>
</view>
<!-- 返回按钮 -->
<view class="back-btn" @click="goBack">
<image class="back-icon" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_back.png" mode="aspectFit"></image>
<text class="back-text">返回上一页</text>
</view>
<!-- xuchao: 门店不存在或加载失败时的提示态,避免纯空白 -->
<view class="page" v-else-if="loaded">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#1a1818" size="40"></u-icon>
</view>
<text class="nav-title">详情</text>
</view>
<view class="load-failed">
<text class="load-failed-text">门店信息加载失败,该门店可能不存在或已下架</text>
</view>
</view>
</view>
</template>
@@ -43,13 +83,41 @@
export default {
data() {
return {
store: null
store: null,
loaded: false
}
},
computed: {
ticketInfoList() {
if (!this.store || !this.store.ticketInfo) return []
return this.store.ticketInfo.split('\n')
},
discountList() {
if (!this.store || !this.store.merchantDiscount) return []
return this.store.merchantDiscount.split('\n')
},
// xuchao: 客服电话列表:按常见分隔符切分;无分隔符连写的多个11位手机号也自动拆开
servicePhoneList() {
if (!this.store || !this.store.servicePhone) return []
const parts = String(this.store.servicePhone).split(/[,,;;、\s\n]+/).filter(Boolean)
const out = []
parts.forEach(p => {
if (p.length > 11 && /^1[3-9]\d{9}$/.test(p.slice(0, 11))) {
let rest = p
const segs = []
while (rest.length >= 11 && /^1[3-9]\d{9}$/.test(rest.slice(0, 11))) {
segs.push(rest.slice(0, 11))
rest = rest.slice(11)
}
if (rest) segs[segs.length - 1] += rest
if (segs.length > 1) {
out.push(...segs)
return
}
}
out.push(p)
})
return out
}
},
onLoad(options) {
@@ -58,6 +126,11 @@ export default {
}
},
methods: {
// xuchao: 点击某个商户电话唤起拨号(多个号码时逐个可点)
callPhone(phone) {
if (!phone) return
uni.makePhoneCall({ phoneNumber: String(phone) })
},
loadDetail(shopId) {
const loc = uni.getStorageSync('lng') && uni.getStorageSync('lat')
? { lng: uni.getStorageSync('lng'), lat: uni.getStorageSync('lat') }
@@ -71,10 +144,19 @@ export default {
if (res.errcode === 0 && res.data) {
this.store = res.data
}
this.loaded = true
}).catch(() => {
this.loaded = true
})
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页,避免返回无响应
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}
}
}
@@ -82,9 +164,9 @@ export default {
<style>
.page {
height: 100vh;
/* xuchao: 顶部导航条占用 56px,改为 min-height 让长内容正常滚动,避免底部被裁切 */
min-height: 100vh;
background-color: #f2f2f7;
overflow: hidden;
}
/* 门店卡片(信息+地址合并容器) */
@@ -207,24 +289,51 @@ export default {
line-height: 1.6;
}
/* 返回按钮 */
.back-btn {
position: fixed;
left: 24px;
bottom: 50px;
/* xuchao: 可点击拨号的电话样式 */
.phone-link {
color: #0088ff;
}
/* xuchao: 加载失败提示态 */
.load-failed {
display: flex;
justify-content: center;
padding: 80px 24px;
}
.load-failed-text {
font-size: 14px;
color: #999999;
}
/* xuchao: 顶部返回导航条 */
.nav-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
height: 56px;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
position: relative;
}
.back-icon {
width: 44px;
height: 44px;
.nav-back {
position: absolute;
left: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
}
.back-text {
font-size: 12px;
color: #666666;
.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>
+22 -6
View File
@@ -16,7 +16,7 @@
</view>
<!-- 场馆列表 -->
<scroll-view class="list-area" scroll-y @scrolltolower="loadMore" :lower-threshold="100">
<view class="list-area">
<view class="venue-card" v-for="item in list" :key="item.shopId" @click="goDetail(item)">
<image class="card-img" :src="item.image" mode="aspectFill" />
<view class="card-info">
@@ -32,9 +32,9 @@
</view>
</view>
<view v-if="hasScrolled" class="nomore-area">
<u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="''" :nomore-text="'我也是有底线的'" />
<u-loadmore :status="loadStatus" :load-text="{ loadmore: '', loading: '加载中...', nomore: '我也是有底线的' }" />
</view>
</scroll-view>
</view>
</view>
</template>
@@ -51,7 +51,8 @@ export default {
lng: '',
lat: '',
loadStatus: 'loadmore',
hasScrolled: false
hasScrolled: false,
reqSeq: 0
}
},
onLoad() {
@@ -64,9 +65,14 @@ export default {
}
this.loadList()
},
// xuchao: 改用页面级触底加载(scroll-view 无固定高度时 scrolltolower 永不触发)
onReachBottom() {
this.loadMore()
},
methods: {
loadList() {
if (this.loading) return
// xuchao: 请求序号 —— 新请求直接取代在途请求,过期响应整体丢弃(修复搜索竞态,故不再用 loading 提前 return)
const seq = ++this.reqSeq
this.loading = true
this.loadStatus = 'loading'
const params = {
@@ -78,6 +84,7 @@ export default {
if (this.lng) params.lng = this.lng
if (this.lat) params.lat = this.lat
this.$u.api.getStoreList(params).then(res => {
if (seq !== this.reqSeq) return
if (res.errcode === 0 && res.data) {
const list = res.data.list || []
if (this.pageNo === 1) {
@@ -86,8 +93,11 @@ export default {
this.list = this.list.concat(list)
}
this.total = res.data.total || 0
// xuchao: 本页不足一页即已到尾部,钉住 total,防止 total 中途变大时空翻
if (list.length < this.pageSize) this.total = this.list.length
}
}).finally(() => {
if (seq !== this.reqSeq) return
this.loading = false
if (this.list.length === 0) {
this.loadStatus = 'nomore'
@@ -106,7 +116,13 @@ export default {
}
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
},
goDetail(item) {
uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId })
+82 -44
View File
@@ -15,7 +15,7 @@
<view class="stats-card">
<image class="stats-gift" src="https://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_xyckr.png" mode="aspectFit"></image>
<text class="stats-label">已产生</text>
<text class="stats-number">7,000</text>
<text class="stats-number">{{ totalWinners.toLocaleString() }}</text>
<text class="stats-label">名幸运持卡人</text>
</view>
</view>
@@ -46,18 +46,26 @@
<!-- 中奖者表格 -->
<view class="table-area">
<view class="table-header">
<text class="table-col col-name">姓名</text>
<text class="table-col col-idcard">身份证</text>
<text class="table-col col-phone">手机号码</text>
</view>
<view class="table-row" v-for="(item, index) in currentList" :key="index">
<text class="table-col col-name">{{ item.memberName }}</text>
<text class="table-col col-idcard">{{ item.memberIdentityNo }}</text>
<text class="table-col col-phone">{{ item.memberPhone }}</text>
</view>
<view class="loadmore-area">
<u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="'上拉加载更多'" :nomore-text="'没有更多了'" />
<template v-if="currentList.length > 0">
<view class="table-header">
<text class="table-col col-name">姓名</text>
<text class="table-col col-idcard">身份证</text>
<text class="table-col col-phone">手机号码</text>
</view>
<view class="table-row" v-for="(item, index) in currentList" :key="index">
<text class="table-col col-name">{{ item.memberName }}</text>
<text class="table-col col-idcard">{{ item.memberIdentityNo }}</text>
<text class="table-col col-phone">{{ item.memberPhone }}</text>
</view>
<!-- xuchao: 名单不足一页时隐藏加载更多 -->
<view class="loadmore-area" v-if="(totals[currentTab] || 0) > pageSize">
<u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="'上拉加载更多'" :nomore-text="'没有更多了'" />
</view>
</template>
<!-- xuchao: 无名单时展示空态 -->
<view class="empty-area" v-else>
<u-icon name="empty" size="60" color="#c8c8c8"></u-icon>
<text class="empty-text">暂无数据</text>
</view>
</view>
</view>
@@ -72,9 +80,10 @@ export default {
winnerData: {},
pageNo: 1,
pageSize: 10,
total: 0,
loading: false,
loadStatus: 'loadmore',
totals: {},
totalWinners: 0,
inFlight: 0,
loadStatusMap: {},
keyword: ''
}
},
@@ -88,6 +97,10 @@ export default {
(item.memberName && item.memberName.toLowerCase().includes(kw)) ||
(item.memberPhone && item.memberPhone.includes(kw))
)
},
// xuchao: 各期独立的加载状态,避免并行请求互相覆盖
loadStatus() {
return this.loadStatusMap[this.currentTab] || 'nomore'
}
},
onLoad() {
@@ -110,45 +123,55 @@ export default {
data[index] = []
})
this.winnerData = data
// xuchao: 预置各期 key,保证 totals/loadStatusMap 的响应式
this.totals = {}
this.loadStatusMap = {}
dates.forEach((date, index) => {
this.$set(this.totals, index, 0)
this.$set(this.loadStatusMap, index, 'loading')
})
// xuchao: 预取各期第一页,汇总已产生总人数
if (this.tabs.length > 0) {
this.loadWinnerList(0)
this.tabs.forEach((tab, index) => {
this.loadWinnerList(index, 1, true)
})
}
}
})
},
loadWinnerList(tabIndex) {
loadWinnerList(tabIndex, pageNo, allowConcurrent) {
if (!this.tabs[tabIndex]) return
if (this.loading) return
this.loading = true
this.loadStatus = 'loading'
if (this.inFlight > 0 && !allowConcurrent) return
this.inFlight++
this.$set(this.loadStatusMap, tabIndex, 'loading')
const drawDate = this.tabs[tabIndex].date
this.$u.api.getWinnerList({
pageNo: this.pageNo,
pageNo: pageNo || this.pageNo,
pageSize: this.pageSize,
drawDate: drawDate
}).then(res => {
if (res.errcode === 0 && res.data) {
const list = res.data.list || []
if (this.pageNo === 1) {
const page = pageNo || this.pageNo
if (page === 1) {
this.$set(this.winnerData, tabIndex, list)
} else {
this.winnerData[tabIndex] = this.winnerData[tabIndex].concat(list)
}
this.total = res.data.total || 0
this.$set(this.totals, tabIndex, res.data.total || 0)
// xuchao: 各期总人数求和,用于顶部幸运持卡人数
this.totalWinners = Object.values(this.totals).reduce((sum, n) => sum + n, 0)
}
}).finally(() => {
this.loading = false
if (this.winnerData[tabIndex].length === 0) {
this.loadStatus = 'nomore'
} else if (this.winnerData[tabIndex].length >= this.total) {
this.loadStatus = 'nomore'
} else {
this.loadStatus = 'loadmore'
}
this.inFlight--
const len = this.winnerData[tabIndex].length
const total = this.totals[tabIndex] || 0
this.$set(this.loadStatusMap, tabIndex, len === 0 || len >= total ? 'nomore' : 'loadmore')
})
},
loadMore() {
if (this.winnerData[this.currentTab].length < this.total && !this.loading) {
const total = this.totals[this.currentTab] || 0
if (this.winnerData[this.currentTab].length < total && this.inFlight === 0) {
this.pageNo++
this.loadWinnerList(this.currentTab)
}
@@ -157,13 +180,20 @@ export default {
this.currentTab = index
this.pageNo = 1
if (this.winnerData[index].length === 0) {
this.loadWinnerList(index)
this.loadWinnerList(index, 1)
} else {
this.loadStatus = this.winnerData[index].length >= this.total ? 'nomore' : 'loadmore'
const total = this.totals[index] || 0
this.$set(this.loadStatusMap, index, this.winnerData[index].length >= total ? 'nomore' : 'loadmore')
}
},
goBack() {
uni.navigateBack()
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
},
onSearch() {
// 前端过滤,无需重新请求
@@ -216,8 +246,7 @@ export default {
.banner-title {
color: #0D387D;
font-family: 'Source Han Serif SC VF', 'Source Han Serif SC', serif;
font-weight: bold;
font-weight: 700;
font-size: 32px;
line-height: normal;
letter-spacing: 0px;
@@ -226,7 +255,6 @@ export default {
.banner-subtitle {
color: #8E8E93;
font-family: 'Source Han Serif SC VF', 'Source Han Serif SC', serif;
font-weight: 600;
font-size: 14px;
line-height: normal;
@@ -251,8 +279,6 @@ export default {
.stats-label {
color: #FFFFFF;
font-family: 'Source Han Sans CN', 'Source Han Sans SC', sans-serif;
font-weight: 400;
font-size: 14px;
line-height: normal;
letter-spacing: 0px;
@@ -261,8 +287,7 @@ export default {
.stats-number {
flex: 1;
font-family: 'Source Han Serif SC VF', 'Source Han Serif SC', serif;
font-weight: 600;
font-weight: 700;
font-size: 36px;
line-height: normal;
letter-spacing: 0px;
@@ -293,7 +318,6 @@ export default {
.tab-label {
color: #B1B1B1;
font-family: 'Source Han Serif SC VF', 'Source Han Serif SC', serif;
font-weight: 600;
font-size: 16px;
line-height: 22px;
@@ -408,4 +432,18 @@ export default {
padding: 24px 0;
color: #999999;
}
/* 空态 */
.empty-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0;
color: #999999;
}
.empty-text {
margin-top: 12px;
font-size: 14px;
}
</style>