diff --git a/.gitignore b/.gitignore index 6e5335f..bfeaeff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ unpackage/ node_modules/ .DS_Store +.playwright-mcp/ diff --git a/App.vue b/App.vue index 9bb63c7..2f80017 100644 --- a/App.vue +++ b/App.vue @@ -14,4 +14,13 @@ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d5544a4 --- /dev/null +++ b/CLAUDE.md @@ -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`(运营向) \ No newline at end of file diff --git a/common/http.api.js b/common/http.api.js index 996822e..1d02815 100644 --- a/common/http.api.js +++ b/common/http.api.js @@ -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, diff --git a/common/http.interceptor.js b/common/http.interceptor.js index 11bac9e..0f824c3 100644 --- a/common/http.interceptor.js +++ b/common/http.interceptor.js @@ -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 diff --git a/pages/agreement/privacy.vue b/pages/agreement/privacy.vue index d87ba4b..9b91b4e 100644 --- a/pages/agreement/privacy.vue +++ b/pages/agreement/privacy.vue @@ -1,14 +1,100 @@ diff --git a/pages/agreement/user.vue b/pages/agreement/user.vue index 16f317f..bf16125 100644 --- a/pages/agreement/user.vue +++ b/pages/agreement/user.vue @@ -1,14 +1,96 @@ diff --git a/pages/discount/discount.vue b/pages/discount/discount.vue index 0c81f58..829bbe0 100644 --- a/pages/discount/discount.vue +++ b/pages/discount/discount.vue @@ -16,13 +16,14 @@ - + {{ item.name }} {{ item.category }} - + + {{ item.address }} @@ -32,9 +33,9 @@ - + - + @@ -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 { diff --git a/pages/index/index.vue b/pages/index/index.vue index 6c5b779..5df3065 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -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; } diff --git a/pages/index/login.vue b/pages/index/login.vue index 24776c5..b5efb86 100644 --- a/pages/index/login.vue +++ b/pages/index/login.vue @@ -1,5 +1,12 @@ @@ -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; } diff --git a/pages/rights/detail.vue b/pages/rights/detail.vue index 9344cb8..1899ce1 100644 --- a/pages/rights/detail.vue +++ b/pages/rights/detail.vue @@ -1,22 +1,23 @@ @@ -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 }) diff --git a/pages/winners/winners.vue b/pages/winners/winners.vue index 374c50e..bbbd651 100644 --- a/pages/winners/winners.vue +++ b/pages/winners/winners.vue @@ -15,7 +15,7 @@ 已产生 - 7,000 + {{ totalWinners.toLocaleString() }} 名幸运持卡人 @@ -46,18 +46,26 @@ - - 姓名 - 身份证 - 手机号码 - - - {{ item.memberName }} - {{ item.memberIdentityNo }} - {{ item.memberPhone }} - - - + + + + + 暂无数据 @@ -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; +}