Compare commits

...
5 Commits
Author SHA1 Message Date
jacobxu666andClaude Haiku 4.5 291be28219 fix: 权益页未中奖空态返回按钮改黑色
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-27 16:56:33 +08:00
jacobxu666andClaude Haiku 4.5 d531a3a8eb docs: 补充项目说明与忽略 playwright 测试产物
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-27 15:17:08 +08:00
jacobxu666andClaude Haiku 4.5 124e77c049 fix: 场馆与折扣页返回按钮回首页
- 直接链接进入(无历史栈)时返回首页兜底

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-27 15:17:08 +08:00
jacobxu666andClaude Haiku 4.5 f2c8364d87 feat: 中奖名单页动态人数、分页提示与空态
- 顶部幸运持卡人数由各期中奖总数动态求和
- 名单不足一页时隐藏加载更多,各期加载状态独立管理
- 无名单时展示暂无数据空态

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-27 15:17:08 +08:00
jacobxu666andClaude Haiku 4.5 06e8223c27 feat: 云闪付入口 token 自动登录及权益状态恢复
- 新增 open-api/hhService/tokenDecode 封装,URL 带 token 时自动解码登录并刷新资格
- 解码失败降级为手机号登录,并用 replaceState 清除地址栏 token 防泄露
- 权益页已登录未认证时自动恢复认证状态,认证取消后停留认证页

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-27 15:17:08 +08:00
9 changed files with 237 additions and 44 deletions
+1
View File
@@ -1,3 +1,4 @@
unpackage/ unpackage/
node_modules/ node_modules/
.DS_Store .DS_Store
.playwright-mcp/
+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-dev.hn12301.net`(= 120.26.254.65,数字化测试环境),**后端不在部署机上**。出现"页面能打开但数据不通"时优先排查 120.26.254.65 的接口
- 页面图片走 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 WinnerDatesUrl = 'mp-api/hhrsWinner/dates';
let WinnerListUrl = 'mp-api/hhrsWinner/list'; let WinnerListUrl = 'mp-api/hhrsWinner/list';
let AppointUrl = 'zhy-api/cardBag/appoint'; let AppointUrl = 'zhy-api/cardBag/appoint';
let TokenDecodeUrl = 'open-api/hhService/tokenDecode';
// 此处第二个参数vm,就是我们在页面使用的this // 此处第二个参数vm,就是我们在页面使用的this
const install = (Vue, vm) => { const install = (Vue, vm) => {
let checkerLogin = (params = {}) => vm.$u.post(LoginUrl, params); let checkerLogin = (params = {}) => vm.$u.post(LoginUrl, params);
let sendLoginVerifyCode = (params = {}) => vm.$u.post(SendVerifyCodeUrl, 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); let getVenueList = (params = {}) => vm.$u.get(VenueListUrl, params);
@@ -57,6 +60,7 @@ const install = (Vue, vm) => {
vm.$u.api = { vm.$u.api = {
checkerLogin, checkerLogin,
sendLoginVerifyCode, sendLoginVerifyCode,
tokenDecode,
getVenueList, getVenueList,
getVenueDetail, getVenueDetail,
getMerchantList, getMerchantList,
+1 -1
View File
@@ -2,7 +2,7 @@
// 同时,我们也可以在此使用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-dev.hn12301.net', baseUrl: 'https://dscp-uat.hn12301.net',
// loadingText: '努力加载中~', // loadingText: '努力加载中~',
// loadingTime: 18800, // loadingTime: 18800,
// 如果将此值设置为true,拦截回调中将会返回服务端返回的所有数据response,而不是response.data // 如果将此值设置为true,拦截回调中将会返回服务端返回的所有数据response,而不是response.data
+6
View File
@@ -104,7 +104,13 @@ export default {
} }
}, },
goBack() { goBack() {
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack() uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}, },
goDetail(item) { goDetail(item) {
uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId }) uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId })
+50 -2
View File
@@ -59,10 +59,56 @@
] ]
} }
}, },
onLoad() { onLoad(options) {
if (options.token) {
this.handleTokenLogin(options.token)
}
this.getLocation() this.getLocation()
}, },
methods: { 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() { getLocation() {
import('@/common/location.js').then(module => { import('@/common/location.js').then(module => {
module.default.getLocation().then(({ lng, lat }) => { module.default.getLocation().then(({ lng, lat }) => {
@@ -83,8 +129,10 @@
} }
const isAuth = uni.getStorageSync('isAuth') const isAuth = uni.getStorageSync('isAuth')
const hasWin = uni.getStorageSync('hasWin') const hasWin = uni.getStorageSync('hasWin')
// xuchao: 最近发起过认证流程(含取消)时忽略旧认证缓存,一律回认证页
const authFlowStarted = uni.getStorageSync('authFlowStarted')
let status = 'auth' let status = 'auth'
if (isAuth) { if (isAuth && !authFlowStarted) {
status = hasWin ? 'info' : 'noWin' status = hasWin ? 'info' : 'noWin'
} }
uni.navigateTo({ url: `/pages/rights/rights?status=${status}` }) uni.navigateTo({ url: `/pages/rights/rights?status=${status}` })
+56 -2
View File
@@ -1,8 +1,8 @@
<template> <template>
<view class="page" :class="{ 'page-auth': status === 'auth' }"> <view class="page" :class="{ 'page-auth': status === 'auth' }">
<!-- 返回按钮(顶部,非auth状态) --> <!-- 返回按钮(顶部,非auth状态) -->
<view class="back-btn" @click="goBack" v-if="status !== 'auth'"> <view class="back-btn" :class="{ 'back-btn--black': status === 'noWin' }" @click="goBack" v-if="status !== 'auth'">
<u-icon name="arrow-left" color="#ffffff" size="40"></u-icon> <u-icon name="arrow-left" :color="status === 'noWin' ? '#000000' : '#ffffff'" size="40"></u-icon>
<text class="back-text">返回</text> <text class="back-text">返回</text>
</view> </view>
<!-- 返回按钮(底部,auth状态) --> <!-- 返回按钮(底部,auth状态) -->
@@ -212,11 +212,46 @@
this.lat = loc.lat this.lat = loc.lat
} }
this.checkAuthResult() this.checkAuthResult()
// xuchao: 本地无认证缓存时自动查询后端恢复已认证状态
this.autoRecoverStatus()
if (this.status === 'info') { if (this.status === 'info') {
this.loadRightsList() this.loadRightsList()
} }
}, },
methods: { methods: {
// xuchao: 已登录但本地无认证缓存时,仅传手机号查询资格以恢复状态(不带证件号,避免触发被动认证)
autoRecoverStatus() {
if (this.status !== 'auth') return
// xuchao: 用户主动发起过认证流程时,取消后应停留在认证页,不自动恢复
if (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
uni.setStorageSync('isAuth', res.data.isAuth)
uni.setStorageSync('hasWin', res.data.hasWin)
uni.setStorageSync('cardNumber', res.data.cardNumber)
uni.setStorageSync('expiresTime', res.data.expiresTime)
// xuchao: 自动恢复成功,清除流程标记
uni.removeStorageSync('authFlowStarted')
if (res.data.isAuth) {
this.status = res.data.hasWin ? 'info' : 'noWin'
if (this.status === 'info') {
this.loadRightsList()
}
}
})
},
// xuchao: 认证取消/未通过:清理旧认证缓存并标记流程已发起,取消后应停留认证页
markAuthCanceled() {
uni.removeStorageSync('isAuth')
uni.removeStorageSync('hasWin')
uni.removeStorageSync('cardNumber')
uni.removeStorageSync('expiresTime')
uni.setStorageSync('authFlowStarted', true)
},
checkAuthResult() { checkAuthResult() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
let responseStr = url.searchParams.get('response') let responseStr = url.searchParams.get('response')
@@ -261,6 +296,8 @@
uni.setStorageSync('hasWin', eRes.data.hasWin) uni.setStorageSync('hasWin', eRes.data.hasWin)
uni.setStorageSync('cardNumber', eRes.data.cardNumber) uni.setStorageSync('cardNumber', eRes.data.cardNumber)
uni.setStorageSync('expiresTime', eRes.data.expiresTime) uni.setStorageSync('expiresTime', eRes.data.expiresTime)
// xuchao: 认证成功,清除流程标记
uni.removeStorageSync('authFlowStarted')
if (eRes.data.isAuth) { if (eRes.data.isAuth) {
this.status = eRes.data.hasWin ? 'info' : 'noWin' this.status = eRes.data.hasWin ? 'info' : 'noWin'
if (this.status === 'info') { if (this.status === 'info') {
@@ -270,6 +307,8 @@
} }
}) })
} else { } else {
// xuchao: 实名未通过视为取消认证,清理缓存并标记
this.markAuthCanceled()
uni.showToast({ uni.showToast({
title: '认证未通过', title: '认证未通过',
icon: 'none' icon: 'none'
@@ -288,6 +327,8 @@
}) })
}) })
} else { } else {
// xuchao: 未返回认证回调视为取消认证,清理缓存并标记
this.markAuthCanceled()
uni.showToast({ uni.showToast({
title: '认证未通过', title: '认证未通过',
icon: 'none' icon: 'none'
@@ -324,6 +365,8 @@
} }
uni.setStorageSync('authName', this.form.realName) uni.setStorageSync('authName', this.form.realName)
uni.setStorageSync('authIdNo', this.form.idNumber) uni.setStorageSync('authIdNo', this.form.idNumber)
// xuchao: 标记已发起认证流程,取消后停留在认证页,不自动恢复
uni.setStorageSync('authFlowStarted', true)
this.loadAliyunSDK().then(() => { this.loadAliyunSDK().then(() => {
const metaInfo = JSON.stringify(window.getMetaInfo()) const metaInfo = JSON.stringify(window.getMetaInfo())
const returnUrl = window.location.origin + window.location.pathname + const returnUrl = window.location.origin + window.location.pathname +
@@ -467,7 +510,13 @@
}) })
}, },
goBack() { goBack() {
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack() uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
} }
} }
} }
@@ -901,6 +950,11 @@
color: #ffffff; color: #ffffff;
} }
/* xuchao: noWin 状态(无中奖空态页)返回按钮改黑色 */
.back-btn--black .back-text {
color: #000000;
}
/* 底部返回按钮 */ /* 底部返回按钮 */
.back-btn-bottom { .back-btn-bottom {
position: fixed; position: fixed;
+6
View File
@@ -104,7 +104,13 @@ export default {
} }
}, },
goBack() { goBack() {
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack() uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}, },
goDetail(item) { goDetail(item) {
uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId }) uni.navigateTo({ url: '/pages/venue/detail?id=' + item.shopId })
+68 -24
View File
@@ -15,7 +15,7 @@
<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://zhihuiyou.oss-cn-shenzhen.aliyuncs.com/hhrs/hhrs_xyckr.png" mode="aspectFit"></image>
<text class="stats-label">已产生</text> <text class="stats-label">已产生</text>
<text class="stats-number">7,000</text> <text class="stats-number">{{ totalWinners.toLocaleString() }}</text>
<text class="stats-label">名幸运持卡人</text> <text class="stats-label">名幸运持卡人</text>
</view> </view>
</view> </view>
@@ -46,6 +46,7 @@
<!-- 中奖者表格 --> <!-- 中奖者表格 -->
<view class="table-area"> <view class="table-area">
<template v-if="currentList.length > 0">
<view class="table-header"> <view class="table-header">
<text class="table-col col-name">姓名</text> <text class="table-col col-name">姓名</text>
<text class="table-col col-idcard">身份证</text> <text class="table-col col-idcard">身份证</text>
@@ -56,9 +57,16 @@
<text class="table-col col-idcard">{{ item.memberIdentityNo }}</text> <text class="table-col col-idcard">{{ item.memberIdentityNo }}</text>
<text class="table-col col-phone">{{ item.memberPhone }}</text> <text class="table-col col-phone">{{ item.memberPhone }}</text>
</view> </view>
<view class="loadmore-area"> <!-- xuchao: 名单不足一页时隐藏加载更多 -->
<view class="loadmore-area" v-if="(totals[currentTab] || 0) > pageSize">
<u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="'上拉加载更多'" :nomore-text="'没有更多了'" /> <u-loadmore :status="loadStatus" :loading-text="'加载中...'" :loadmore-text="'上拉加载更多'" :nomore-text="'没有更多了'" />
</view> </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>
</view> </view>
</template> </template>
@@ -72,9 +80,10 @@ export default {
winnerData: {}, winnerData: {},
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
total: 0, totals: {},
loading: false, totalWinners: 0,
loadStatus: 'loadmore', inFlight: 0,
loadStatusMap: {},
keyword: '' keyword: ''
} }
}, },
@@ -88,6 +97,10 @@ export default {
(item.memberName && item.memberName.toLowerCase().includes(kw)) || (item.memberName && item.memberName.toLowerCase().includes(kw)) ||
(item.memberPhone && item.memberPhone.includes(kw)) (item.memberPhone && item.memberPhone.includes(kw))
) )
},
// xuchao: 各期独立的加载状态,避免并行请求互相覆盖
loadStatus() {
return this.loadStatusMap[this.currentTab] || 'nomore'
} }
}, },
onLoad() { onLoad() {
@@ -110,45 +123,55 @@ export default {
data[index] = [] data[index] = []
}) })
this.winnerData = data 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) { 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.tabs[tabIndex]) return
if (this.loading) return if (this.inFlight > 0 && !allowConcurrent) return
this.loading = true this.inFlight++
this.loadStatus = '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: this.pageNo, pageNo: pageNo || this.pageNo,
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 || []
if (this.pageNo === 1) { const page = pageNo || this.pageNo
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.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(() => { }).finally(() => {
this.loading = false this.inFlight--
if (this.winnerData[tabIndex].length === 0) { const len = this.winnerData[tabIndex].length
this.loadStatus = 'nomore' const total = this.totals[tabIndex] || 0
} else if (this.winnerData[tabIndex].length >= this.total) { this.$set(this.loadStatusMap, tabIndex, len === 0 || len >= total ? 'nomore' : 'loadmore')
this.loadStatus = 'nomore'
} else {
this.loadStatus = 'loadmore'
}
}) })
}, },
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.pageNo++
this.loadWinnerList(this.currentTab) this.loadWinnerList(this.currentTab)
} }
@@ -157,13 +180,20 @@ export default {
this.currentTab = index this.currentTab = index
this.pageNo = 1 this.pageNo = 1
if (this.winnerData[index].length === 0) { if (this.winnerData[index].length === 0) {
this.loadWinnerList(index) this.loadWinnerList(index, 1)
} else { } 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() { goBack() {
// xuchao: 直接通过链接进入(无历史栈)时回到首页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack() uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}, },
onSearch() { onSearch() {
// 前端过滤,无需重新请求 // 前端过滤,无需重新请求
@@ -408,4 +438,18 @@ export default {
padding: 24px 0; padding: 24px 0;
color: #999999; 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> </style>