diff --git a/miniprogram/components/login-modal/login-modal.wxml b/miniprogram/components/login-modal/login-modal.wxml index e4022dd0..eb215be8 100644 --- a/miniprogram/components/login-modal/login-modal.wxml +++ b/miniprogram/components/login-modal/login-modal.wxml @@ -5,17 +5,7 @@ 登录 卡若创业派对 {{desc}} - - - - - 为获取手机号,请先同意《用户隐私保护指引》 - - - 取消 + 我已阅读并同意 @@ -23,5 +13,16 @@ 《隐私政策》 + + + + + 为获取手机号,请先同意《用户隐私保护指引》 + + + 取消 diff --git a/miniprogram/components/login-modal/login-modal.wxss b/miniprogram/components/login-modal/login-modal.wxss index 56d59b28..8bb7e0e0 100644 --- a/miniprogram/components/login-modal/login-modal.wxss +++ b/miniprogram/components/login-modal/login-modal.wxss @@ -13,8 +13,9 @@ padding: 48rpx; } .modal-content { + position: relative; width: 100%; - max-width: 600rpx; + max-width: 560rpx; background: #1c1c1e; border-radius: 32rpx; overflow: hidden; @@ -25,6 +26,10 @@ right: 24rpx; z-index: 1; padding: 16rpx; + margin: 0; + background: transparent; + border: none; + line-height: 0; } .login-modal { padding: 48rpx 32rpx; @@ -41,18 +46,30 @@ color: #ffffff; display: block; margin-bottom: 16rpx; + text-shadow: none; + -webkit-text-fill-color: #ffffff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .login-desc { font-size: 26rpx; color: rgba(255, 255, 255, 0.5); display: block; - margin-bottom: 48rpx; + margin-bottom: 32rpx; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .btn-login { display: flex; align-items: center; justify-content: center; gap: 16rpx; + width: 100%; + box-sizing: border-box; padding: 28rpx; background: linear-gradient(135deg, #00CED1, #00B4D8); color: #ffffff; @@ -61,19 +78,31 @@ border-radius: 24rpx; margin-bottom: 20rpx; border: none; + line-height: 1.2; } .btn-login::after { border: none; } .btn-login-icon { - font-size: 36rpx; - line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.btn-login-disabled { opacity: 0.55; } +.btn-login[disabled] { + opacity: 0.55; } -.btn-login-disabled { opacity: 0.6; } .login-modal-cancel { - margin-top: 24rpx; - padding: 24rpx; + display: block; + width: 100%; + margin-top: 8rpx; + padding: 20rpx 24rpx 8rpx; font-size: 28rpx; color: rgba(255, 255, 255, 0.5); text-align: center; + background: transparent; + border: none; + box-sizing: border-box; + cursor: pointer; } .privacy-wechat-row { margin: 24rpx 0; @@ -100,11 +129,16 @@ .login-agree-row { display: flex; flex-wrap: wrap; - align-items: center; - justify-content: center; - margin-top: 32rpx; + align-items: flex-start; + justify-content: flex-start; + width: 100%; + box-sizing: border-box; + margin-top: 0; + margin-bottom: 32rpx; + padding: 0 8rpx; font-size: 22rpx; color: rgba(255, 255, 255, 0.5); + text-align: left; } .agree-checkbox { width: 32rpx; @@ -127,6 +161,11 @@ color: #00CED1; text-decoration: underline; padding: 0 4rpx; + margin: 0; + background: transparent; + border: none; + font-size: inherit; + line-height: inherit; } /* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */ diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.js b/miniprogram/pages/avatar-nickname/avatar-nickname.js index 2bc4bd32..2428dbfa 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.js +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.js @@ -5,6 +5,7 @@ */ const app = getApp() const { trackClick } = require('../../utils/trackClick') +const { resolveChooseAvatarFilePath, pickLocalAvatarImagePath } = require('../../utils/util.js') function isPlaceholderNickname(n) { const s = (n || '').trim() @@ -133,20 +134,41 @@ Page({ fail: () => {}, }) }, - async onChooseAvatar(e) { - const tempAvatarUrl = e.detail?.avatarUrl - if (!tempAvatarUrl) return + onTapPickAvatar() { trackClick('avatar_nickname', 'btn_click', '选择头像') - await this.uploadAndSaveAvatar(tempAvatarUrl) + const run = () => { + pickLocalAvatarImagePath() + .then((p) => this.uploadAndSaveAvatar(p)) + .catch((err) => { + if (err && err.cancelled) return + wx.showToast({ title: (err && err.message) || '选择图片失败', icon: 'none' }) + }) + } + if (typeof wx.requirePrivacyAuthorize === 'function') { + wx.requirePrivacyAuthorize({ + success: run, + fail: () => wx.showToast({ title: '需同意隐私指引后再换头像', icon: 'none' }), + }) + } else { + run() + } }, - async uploadAndSaveAvatar(tempPath) { + async uploadAndSaveAvatar(tempUrl) { wx.showLoading({ title: '上传中...', mask: true }) try { + let filePath + try { + filePath = await resolveChooseAvatarFilePath(tempUrl) + } catch (readErr) { + wx.hideLoading() + wx.showToast({ title: readErr.message || '无法读取所选图片', icon: 'none' }) + return + } const uploadRes = await new Promise((resolve, reject) => { wx.uploadFile({ url: app.globalData.baseUrl + '/api/miniprogram/upload', - filePath: tempPath, + filePath, name: 'file', formData: { folder: 'avatars' }, success: (r) => { diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml index e2c2e489..d770dde7 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml @@ -18,19 +18,18 @@ 完成后即可继续使用小程序。 - + 头像* - + diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxss b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss index a81b6c71..3f4a3bd6 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.wxss +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss @@ -99,14 +99,13 @@ border-color: rgba(94, 234, 212, 0.55); box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25); } -/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */ +/* 头像点击区:view + chooseMedia(原 chooseAvatar button) */ .avatar-wrap-btn { display: flex; align-items: center; justify-content: center; - padding: 0; margin: 0; background: transparent; border: none; + padding: 0; margin: 0; background: transparent; width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible; flex-shrink: 0; } -.avatar-wrap-btn::after { border: none; } .avatar-wrap { position: relative; width: 192rpx; @@ -138,7 +137,7 @@ color: #5EEAD4; background: rgba(94, 234, 212, 0.2); } -/* 盖住 chooseAvatar 原生层默认头像,避免误以为是业务「回显」 */ +/* 无头像时占位层,避免圆内误显为「已有头像」 */ .avatar-native-mask { position: absolute; left: 0; diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss index 4ac27317..ca3ab047 100644 --- a/miniprogram/pages/chapters/chapters.wxss +++ b/miniprogram/pages/chapters/chapters.wxss @@ -452,6 +452,9 @@ font-size: 20rpx; color: rgba(255, 255, 255, 0.35); margin-top: 4rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .part-right { diff --git a/miniprogram/pages/dev-login/dev-login.js b/miniprogram/pages/dev-login/dev-login.js index dc691a44..0056c9e1 100644 --- a/miniprogram/pages/dev-login/dev-login.js +++ b/miniprogram/pages/dev-login/dev-login.js @@ -41,12 +41,17 @@ Page({ wx.showToast({ title: '请输入11位手机号', icon: 'none' }) return } + const pwd = String(password || '').trim() + if (pwd.length < 6) { + wx.showToast({ title: '密码至少6位', icon: 'none' }) + return + } this.setData({ loading: true }) try { const res = await app.request('/api/miniprogram/dev/login-by-phone', { method: 'POST', - data: { phone, password: password || '' } + data: { phone, password: pwd } }) if (res.success && res.data) { diff --git a/miniprogram/pages/dev-login/dev-login.wxss b/miniprogram/pages/dev-login/dev-login.wxss index c21b507d..5465cb3f 100644 --- a/miniprogram/pages/dev-login/dev-login.wxss +++ b/miniprogram/pages/dev-login/dev-login.wxss @@ -14,7 +14,19 @@ .form-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; border: 2rpx solid rgba(0,206,209,0.2); } .form-item { margin-bottom: 32rpx; } -.form-item:last-of-type { margin-bottom: 48rpx; } +.form-item:last-of-type { margin-bottom: 24rpx; } +.login-forgot-link { + display: block; + width: 100%; + text-align: right; + font-size: 26rpx; + color: #00ced1; + text-decoration: underline; + margin: 0 0 32rpx; + background: transparent; + border: none; + padding: 0; +} .form-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; margin-bottom: 16rpx; } .form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; } .form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; } diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 01c46c33..18073b7f 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -673,75 +673,6 @@ Page({ trackClick('my', 'btn_click', '资料编辑') wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1&wizard=0' }) }, - - async onChooseAvatar(e) { - const tempAvatarUrl = e.detail?.avatarUrl - if (!tempAvatarUrl) return - wx.showLoading({ title: '上传中...', mask: true }) - - try { - // 1. 先上传图片到服务器 - console.log('[My] 开始上传头像:', tempAvatarUrl) - - const uploadRes = await new Promise((resolve, reject) => { - wx.uploadFile({ - url: app.globalData.baseUrl + '/api/miniprogram/upload', - filePath: tempAvatarUrl, - name: 'file', - formData: { - folder: 'avatars' - }, - success: (res) => { - try { - const data = JSON.parse(res.data) - if (data.success) { - resolve(data) - } else { - reject(new Error(data.error || '上传失败')) - } - } catch (err) { - reject(new Error('解析响应失败')) - } - }, - fail: (err) => { - reject(err) - } - }) - }) - - // 2. 获取上传后的完整URL(显示用);保存时只传路径 - let avatarUrl = uploadRes.data?.url || uploadRes.url - if (avatarUrl && !avatarUrl.startsWith('http')) { - avatarUrl = app.globalData.baseUrl + avatarUrl - } - console.log('[My] 头像上传成功:', avatarUrl) - - // 3. 更新本地头像 - const userInfo = this.data.userInfo - userInfo.avatar = avatarUrl - this.setData({ userInfo }) - this._refreshMyAvatarDisplay(userInfo) - app.globalData.userInfo = userInfo - wx.setStorageSync('userInfo', userInfo) - - // 4. 同步到服务器数据库(只保存路径,不含域名) - await app.request('/api/miniprogram/user/update', { - method: 'POST', - data: { userId: userInfo.id, avatar: avatarUrl } - }) - - wx.hideLoading() - wx.showToast({ title: '头像更新成功', icon: 'success' }) - - } catch (e) { - wx.hideLoading() - console.error('[My] 上传头像失败:', e) - wx.showToast({ - title: e.message || '上传失败,请重试', - icon: 'none' - }) - } - }, // 微信原生获取昵称回调(针对 input type="nickname" 的 bindblur 或 bindchange) async handleNicknameChange(nickname) { diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index b9fb6884..0ceecd07 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -9,7 +9,7 @@ * 表单展示:普通用户仅展示 温馨提示、头像、昵称、MBTI、地区、行业、业务体量、职位、核心联系方式;VIP 展示全部 */ const app = getApp() -const { toAvatarPath } = require('../../utils/util.js') +const { toAvatarPath, pickLocalAvatarImagePath } = require('../../utils/util.js') const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP'] @@ -408,17 +408,35 @@ Page({ } }, - // 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照) - async onChooseAvatar(e) { - const tempAvatarUrl = e.detail?.avatarUrl - if (!tempAvatarUrl) return + /** 点击头像:相册/相机选图(不使用 open-type=chooseAvatar,避免 Windows 开发者工具 http://tmp 渲染报错) */ + onTapPickAvatar() { + const run = () => { + pickLocalAvatarImagePath() + .then((filePath) => this._uploadChosenAvatar(filePath)) + .catch((err) => { + if (err && err.cancelled) return + wx.showToast({ title: (err && err.message) || '选择图片失败', icon: 'none' }) + }) + } + if (typeof wx.requirePrivacyAuthorize === 'function') { + wx.requirePrivacyAuthorize({ + success: run, + fail: () => wx.showToast({ title: '需同意隐私指引后再换头像', icon: 'none' }), + }) + } else { + run() + } + }, + + async _uploadChosenAvatar(filePath) { + if (!filePath) return wx.showLoading({ title: '上传中...', mask: true }) try { const uploadRes = await new Promise((resolve, reject) => { wx.uploadFile({ url: app.globalData.baseUrl + '/api/miniprogram/upload', - filePath: tempAvatarUrl, + filePath, name: 'file', formData: { folder: 'avatars' }, success: (r) => { diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index 24852ffb..e379bdea 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -31,7 +31,7 @@ 第 1 步:先设置对外展示的头像与昵称 - + @@ -181,7 +181,7 @@ - + diff --git a/miniprogram/pages/profile-edit/profile-edit.wxss b/miniprogram/pages/profile-edit/profile-edit.wxss index fc8074a6..0bbc7e8c 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxss +++ b/miniprogram/pages/profile-edit/profile-edit.wxss @@ -41,13 +41,12 @@ .tip-text { font-size: 26rpx; color: rgba(94,234,212,0.95); line-height: 1.6; } .avatar-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; } -/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */ +/* 头像点击区(原 chooseAvatar button,现为 view + chooseMedia) */ .avatar-wrap-btn { display: flex; align-items: center; justify-content: center; - padding: 0; margin: 0; background: transparent; border: none; + padding: 0; margin: 0; background: transparent; width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible; } -.avatar-wrap-btn::after { border: none; } .avatar-wrap { position: relative; width: 192rpx; height: 192rpx; border-radius: 50%; border: 4rpx solid #5EEAD4; box-shadow: 0 0 30rpx rgba(94,234,212,0.3); diff --git a/miniprogram/utils/util.js b/miniprogram/utils/util.js index dd762edf..741bc4d6 100644 --- a/miniprogram/utils/util.js +++ b/miniprogram/utils/util.js @@ -198,6 +198,108 @@ const toAvatarPath = url => { return url } +/** + * chooseAvatar 返回的 avatarUrl 常为 http(s)://tmp/... ,渲染层可用的临时路径,但 wx.uploadFile 需本地文件路径。 + * 约定:网络地址先 wx.downloadFile 得到 tempFilePath;已是 wxfile:// 则直接上传。 + * + * @param {string} avatarUrl + * @returns {Promise} + */ +function resolveChooseAvatarFilePath(avatarUrl) { + return new Promise((resolve, reject) => { + if (!avatarUrl || typeof avatarUrl !== 'string') { + reject(new Error('无效的头像路径')) + return + } + const trimmed = avatarUrl.trim() + if (!trimmed) { + reject(new Error('无效的头像路径')) + return + } + if (trimmed.startsWith('wxfile://')) { + resolve(trimmed) + return + } + if (/^https?:\/\//i.test(trimmed)) { + wx.downloadFile({ + url: trimmed, + success: res => { + if (res.statusCode === 200 && res.tempFilePath) resolve(res.tempFilePath) + else { + wx.getImageInfo({ + src: trimmed, + success: img => { + if (img.path) resolve(img.path) + else reject(new Error('图片获取失败')) + }, + fail: () => reject(new Error('图片获取失败')), + }) + } + }, + fail: () => { + wx.getImageInfo({ + src: trimmed, + success: img => { + if (img.path) resolve(img.path) + else reject(new Error('图片获取失败')) + }, + fail: () => reject(new Error('图片获取失败')), + }) + }, + }) + return + } + resolve(trimmed) + }) +} + +/** + * 相册/相机选头像用本地路径(供 wx.uploadFile),避开开发者工具里 chooseAvatar → http://tmp 渲染失败。 + * 优先 chooseMedia,低版本兜底 chooseImage。 + * @returns {Promise} + */ +function pickLocalAvatarImagePath() { + return new Promise((resolve, reject) => { + const fail = err => { + const em = (err && (err.errMsg || err.message)) || '' + const s = String(em) + if (s.includes('cancel') || s.includes('取消')) reject(Object.assign(err || {}, { cancelled: true })) + else reject(err || new Error('选择图片失败')) + } + if (typeof wx.chooseMedia === 'function') { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'], + success: res => { + const files = res.tempFiles || [] + const p = files[0] && files[0].tempFilePath + if (p) resolve(p) + else reject(new Error('未选择图片')) + }, + fail, + }) + return + } + if (typeof wx.chooseImage === 'function') { + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: res => { + const arr = res.tempFilePaths || [] + if (arr[0]) resolve(arr[0]) + else reject(new Error('未选择图片')) + }, + fail, + }) + return + } + reject(new Error('当前环境不支持选择图片')) + }) +} + module.exports = { formatTime, formatDate, @@ -217,5 +319,7 @@ module.exports = { showLoading, hideLoading, showConfirm, - toAvatarPath + toAvatarPath, + resolveChooseAvatarFilePath, + pickLocalAvatarImagePath, } diff --git a/reactH5/.env.development.example b/reactH5/.env.development.example new file mode 100644 index 00000000..179f0959 --- /dev/null +++ b/reactH5/.env.development.example @@ -0,0 +1,16 @@ +# reactH5 本地开发 · 对接 soul-api +# 复制为 .env.development 后按需修改(.env.development 可加入本地 .gitignore) + +# ---------- 模式 A(推荐):浏览器请求相对路径 /api,由 Vite 转发到 soul-api ---------- +# 留空则 fetch 走当前 dev 源(如 http://localhost:5173/api/...),依赖下方代理 +VITE_API_BASE_URL= + +# 代理目标:vite.config 默认已是 http://127.0.0.1:9100;仅连线上时取消注释下一行 +# VITE_PROXY_TARGET=https://soulapi.quwanzhi.com + +# ---------- 若 h5/login-by-phone 仍 403 ---------- +# 说明代理到的 soul-api 在 release 且未开 H5:线上须 H5_PHONE_LOGIN_ENABLED=1;本地须 GIN_MODE=debug 等(见接口返回 error 全文)。 + +# ---------- 模式 B:浏览器直连 soul-api(需 soul-api CORS 含 H5 源,默认已含 localhost:5173)---------- +# VITE_API_BASE_URL=http://127.0.0.1:9100 +# 直连时 VITE_PROXY_TARGET 不参与浏览器请求,可保留或删除 diff --git a/reactH5/README.md b/reactH5/README.md index a7c8af59..602fb75a 100644 --- a/reactH5/README.md +++ b/reactH5/README.md @@ -43,8 +43,17 @@ npm run dev 从首页/我的等页进入 **`/login` 手机号登录**(旧链接 `/dev-login` 会重定向到 `/login`)时,登录成功后会 **回到进入前的路由**(内部路径,避免跳回登录页循环)。 -- 请求基址:通过 `VITE_API_BASE_URL` 注入;默认开发态为空字符串,**走同域**并由 Vite `server.proxy` 将 `/api` 转发到 `https://soulapi.quwanzhi.com`(见 `vite.config.ts`)。 -- 在 `.env.development` / `.env.production` 中可覆盖 `VITE_API_BASE_URL`(如直连完整 API 根 URL)。 +### 对接本地 soul-api + +1. 启动 **soul-api**(本仓库联调约定 **`PORT=9100`**,需在 soul-api 环境变量中设置;另需 `DB_DSN` 等,见 `soul-api` 文档)。 +2. **可不建 `.env`**:Vite 已默认把 `/api` 代理到 **`http://127.0.0.1:9100`**。若本机 API 端口不同,复制 **`.env.development.example`** 为 **`.env.development`** 并设置 `VITE_PROXY_TARGET`。保持 **`VITE_API_BASE_URL` 为空** 即走代理。 +3. **直连模式**(不用代理):`.env.development` 中设 `VITE_API_BASE_URL=http://127.0.0.1:9100`;soul-api 默认 CORS 已包含 `http://localhost:5173` / `127.0.0.1:5173`,其它端口请加环境变量 **`CORS_ORIGINS`**(逗号分隔)与 soul-api 合并。 + +未配置 `.env` 时:Vite 默认将 `/api` 代理到 **`http://127.0.0.1:9100`**,便于本地联调且避免误连线上导致 **`h5/login-by-phone` 403**。 +若需打线上 API,在 `.env.development` 设置 **`VITE_PROXY_TARGET=https://soulapi.quwanzhi.com`**(或你的 API 根),且服务端须 **`H5_PHONE_LOGIN_ENABLED=1`**。 + +- 请求基址:生产/预览通过 `VITE_API_BASE_URL` 指向 API 根(含协议与端口,**无尾部斜杠**);开发态通常留空走代理。 +- 详见 **`vite.config.ts`**(`VITE_PROXY_TARGET`)与 **`src/api/request.ts`**(`getApiBaseUrl`)。 ## 生产环境(CORS / 反代) @@ -60,8 +69,9 @@ npm run dev ## 手机号登录(与小程序同账号) -- 接口:`POST /api/miniprogram/h5/login-by-phone`(与 `/api/miniprogram/dev/login-by-phone` 为同一实现),**按库内已绑定手机**匹配用户,老用户直接输入注册手机号即可。 -- 开发环境(`APP_ENV=development`)下默认可用;**生产**需在 soul-api 环境变量中设置 `H5_PHONE_LOGIN_ENABLED=1`(或 `true`),否则该接口会返回 403。 +- 接口:`POST /api/miniprogram/h5/login-by-phone`(与 `/api/miniprogram/dev/login-by-phone` 为同一实现),**按库内已绑定手机**匹配用户;**须至少 6 位密码**(库中尚无 `password_hash` 时首次提交会写入并登录,已有则校验 bcrypt)。 +- 忘记密码:`POST /api/miniprogram/h5/reset-password`,body `{ "phone", "newPassword" }`(≥6 位),与登录共用 `H5_PHONE_LOGIN_ENABLED` / 开发环境开关;**无短信验码**,生产请配合网关限流与风控。 +- 开发环境(`APP_ENV=development`)下默认可用;**生产**需在 soul-api 环境变量中设置 `H5_PHONE_LOGIN_ENABLED=1`(或 `true`),否则上述接口会返回 403。 ## 微信能力(H5 与小程序差异) diff --git a/reactH5/docs/H5_SHARE.md b/reactH5/docs/H5_SHARE.md new file mode 100644 index 00000000..6f2fa403 --- /dev/null +++ b/reactH5/docs/H5_SHARE.md @@ -0,0 +1,27 @@ +# H5(reactH5)分享方案 + +与微信小程序 **拆分**:小程序依赖 `wx`、转发卡片、`onShareAppMessage`、`showShareMenu` 等;H5 仅能使用浏览器能力,**不提供**与小程序一致的分享卡片与朋友圈原生入口。 + +## 1. 设计原则 + +| 原则 | 说明 | +|------|------| +| 能力边界 | H5 只做 `navigator.share`、剪贴板、落地链接、可选 `document.title`;不假设微信 JS-SDK。 | +| 文案边界 | 不使用「右上角 ··· → 朋友圈」等仅小程序成立的引导;推广文案附 **可点击的 https 链接**。 | +| 代码边界 | 分享 URL 构建、系统分享/复制回退统一在 `src/utils/h5Share.ts`,页面只组参数与 Toast。 | + +## 2. 用户可见的三条路径(阅读页) + +1. **系统分享**:在支持的移动浏览器呼起原生分享面板(Web Share API)。 +2. **复制推广链接**:带 `id` / `mid` / `ref` 的当前站阅读 URL,便于私聊粘贴。 +3. **复制推广文案**:章节标题 + 后台配置的预览占比说明尾注 + **落地链接**(与小程序「搜小程序阅读全文」解耦)。 + +## 3. 与小程序的差异(产品说明) + +- 小程序:**分享消息卡片**、可选缩略图、路径 `/pages/read/read`。 +- H5:**普通 URL**;在社交 App 内打开时预览依赖对方爬虫(本项目不在此做服务端动态 OG,仅可做前端 `document.title` 等轻量优化)。 + +## 4. 扩展占位 + +- 需在其它页面分享时:`import { buildReadShareUrl, shareOrCopyPageUrl } from '@/utils/h5Share'`。 +- 若将来在微信 **内置浏览器** 内接入 JSSDK,应单独模块 `wechatJssdkShare.ts`,**禁止**与 `h5Share` 混写,以免破坏非微信环境。 diff --git a/reactH5/docs/MINIPROGRAM_H5_PARITY.md b/reactH5/docs/MINIPROGRAM_H5_PARITY.md index 68c9a48a..50446f6e 100644 --- a/reactH5/docs/MINIPROGRAM_H5_PARITY.md +++ b/reactH5/docs/MINIPROGRAM_H5_PARITY.md @@ -29,7 +29,7 @@ | 登录 | 微信手机号组件 + 协议勾选 | 手机号 + `/h5/login-by-phone`;弹窗**强制协议勾选**与小程序流程对齐 | | 支付 | `wx.requestPayment` | 提示使用小程序;不下发真实支付 | | 一键收款 / 商户转账 | 微信 `requestMerchantTransfer` | 提示小程序完成 | -| 分享 / 场景值 | 微信分享 | 未接 | +| 分享 / 场景值 | 微信分享、`onShareAppMessage` | **H5 独立方案**:Web Share、`h5Share` 工具、`docs/H5_SHARE.md` | | 我的页子功能 | 跳转各页 | 已改为 **同路径壳页**(返回栈一致),非仅 Toast | ## 4. 样式 1:1 策略 diff --git a/reactH5/scripts/sync_styles_from_miniprogram.py b/reactH5/scripts/sync_styles_from_miniprogram.py index 827c23e3..49893b5e 100644 --- a/reactH5/scripts/sync_styles_from_miniprogram.py +++ b/reactH5/scripts/sync_styles_from_miniprogram.py @@ -78,39 +78,156 @@ TABBAR_CSS_H5_TAIL = """ CHAPTERS_CSS_H5_TAIL = """ -/* ----- H5:目录页 scoped 兜底,避免与首页/匹配页同名类互相覆盖导致样式错乱 ----- */ +/* ----- H5:目录页 scoped 兜底(与 chapters.wxss 一致),避免首页 Index 同名 .part-* 覆盖宽高 ----- */ .chapters-page.page { padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); box-sizing: border-box; } .chapters-page .chapters-content { - padding: 16rpx 24rpx; + /* 与导航栏 nav-content 左右 32rpx 对齐,卡片区略收窄(参考小程序视觉边距) */ + padding: 16rpx 32rpx; width: 100%; box-sizing: border-box; } +.chapters-page .book-card-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6rpx; +} .chapters-page .book-card-title { font-size: 30rpx; font-weight: 700; color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 1rpx; +} +.chapters-page .book-card-subtitle { + font-size: 22rpx; + color: rgba(255, 255, 255, 0.35); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chapters-page .part-item { + /* 盖住全局 IndexPage.css .part-item(padding/背景/边框),只保留外层容器与小程序一致 */ + display: block; + width: 100%; + margin-bottom: 12rpx; + padding: 0; + gap: 0; + align-items: unset; + justify-content: unset; + flex-wrap: unset; + background: transparent; + border: none; + border-radius: 0; + box-sizing: border-box; +} +.chapters-page .part-item:active { + transform: none; + background: transparent; +} +.chapters-page .part-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22rpx 24rpx; + background: rgba(28, 28, 30, 0.7); + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.04); + width: 100%; + box-sizing: border-box; +} +.chapters-page .part-left { + display: flex; + align-items: center; + gap: 20rpx; + flex: 1; + min-width: 0; +} +.chapters-page .part-right { + display: flex; + align-items: center; + gap: 12rpx; + flex-shrink: 0; + white-space: nowrap; +} +.chapters-page .part-icon { + width: 56rpx; + height: 56rpx; + min-width: 56rpx; + min-height: 56rpx; + border-radius: 14rpx; + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + font-weight: 700; + color: #ffffff; + flex-shrink: 0; +} +.chapters-page .part-icon-emoji { + font-size: 28rpx; + font-weight: 400; + line-height: 1; + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.25); +} +.chapters-page .part-icon-img { + width: 56rpx; + height: 56rpx; + border-radius: 14rpx; + flex-shrink: 0; +} +.chapters-page .part-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; } .chapters-page .part-title { font-size: 27rpx; font-weight: 600; color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + margin-bottom: 0; +} +.chapters-page .part-subtitle { + font-size: 20rpx; + color: rgba(255, 255, 255, 0.35); + margin-top: 4rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} +.chapters-page .part-count { + font-size: 22rpx; + color: rgba(255, 255, 255, 0.35); +} +.chapters-page .part-arrow { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.4); } .chapters-page .section-title { font-size: 25rpx; color: #ffffff; - white-space: normal; - word-break: break-word; - overflow-wrap: anywhere; - line-height: 1.35; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; } .chapters-page .tag { display: inline-flex; @@ -122,6 +239,7 @@ CHAPTERS_CSS_H5_TAIL = """ border-radius: 8rpx; box-sizing: border-box; text-align: center; + flex-shrink: 0; } .chapters-page .tag-free { background: rgba(0, 206, 209, 0.1); @@ -131,39 +249,16 @@ CHAPTERS_CSS_H5_TAIL = """ display: flex; width: 100%; box-sizing: border-box; - align-items: flex-start; + align-items: center; } .chapters-page .chapters-list { margin-top: 12rpx; margin-left: 12rpx; } -.chapters-page .part-item { - display: block; - width: 100%; - margin-bottom: 12rpx; -} .chapters-page .part-item > .chapters-list { display: block; width: 100%; } -.chapters-page .part-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - column-gap: 12rpx; - width: 100%; - box-sizing: border-box; -} -.chapters-page .part-left { - min-width: 0; -} -.chapters-page .part-right { - justify-self: end; - white-space: nowrap; - display: inline-flex; - align-items: center; - gap: 12rpx; -} .chapters-page .section-list { display: block; width: 100%; @@ -180,7 +275,7 @@ CHAPTERS_CSS_H5_TAIL = """ .chapters-page .section-left { display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; gap: 14rpx; flex: 1; min-width: 0; @@ -191,7 +286,6 @@ CHAPTERS_CSS_H5_TAIL = """ gap: 12rpx; flex-shrink: 0; margin-left: 12rpx; - padding-top: 2rpx; } .chapters-page .card { background: rgba(28, 28, 30, 0.7); diff --git a/reactH5/src/api/readExtras.ts b/reactH5/src/api/readExtras.ts new file mode 100644 index 00000000..4b6876f4 --- /dev/null +++ b/reactH5/src/api/readExtras.ts @@ -0,0 +1,69 @@ +import { request } from '@/api/request' + +export type MentionPersonCfg = { + personId?: string + token?: string + name?: string + label?: string + aliases?: string +} + +export type LinkTagCfg = Record & { + label?: string + url?: string + type?: string + pagePath?: string + tagId?: string + appId?: string + mpKey?: string + passPhone?: boolean + phoneParamName?: string + aliases?: string +} + +let cachePersons: MentionPersonCfg[] | null = null +let cacheLinkTags: LinkTagCfg[] | null = null +let cacheTs = 0 +const TTL = 5 * 60 * 1000 + +export async function fetchReadExtras(force = false): Promise<{ + persons: MentionPersonCfg[] + linkTags: LinkTagCfg[] +}> { + const now = Date.now() + if (!force && cachePersons != null && cacheLinkTags != null && now - cacheTs < TTL) { + return { persons: cachePersons, linkTags: cacheLinkTags } + } + try { + const res = await request<{ + success?: boolean + mentionPersons?: MentionPersonCfg[] + linkTags?: LinkTagCfg[] + }>({ url: '/api/miniprogram/config/read-extras', silent: true, timeout: 5000 }) + const persons = Array.isArray(res?.mentionPersons) ? res.mentionPersons : [] + const linkTags = Array.isArray(res?.linkTags) ? res.linkTags : [] + cachePersons = persons + cacheLinkTags = linkTags + cacheTs = now + return { persons, linkTags } + } catch { + return { + persons: cachePersons || [], + linkTags: cacheLinkTags || [], + } + } +} + +export function getContentParseConfig(assetBase: string, extras: { + persons: MentionPersonCfg[] + linkTags: LinkTagCfg[] +}) { + const persons = extras.persons.map((p) => ({ + personId: p.personId || '', + token: p.token || '', + name: (p.name || '').trim(), + label: (p.label || '').trim(), + aliases: p.aliases != null ? String(p.aliases) : '', + })) + return { persons, linkTags: extras.linkTags, assetBase: String(assetBase || '').replace(/\/$/, '') } +} diff --git a/reactH5/src/components/LoginModal/LoginModal.css b/reactH5/src/components/LoginModal/LoginModal.css index 70f607c8..9dfbb83f 100644 --- a/reactH5/src/components/LoginModal/LoginModal.css +++ b/reactH5/src/components/LoginModal/LoginModal.css @@ -14,8 +14,9 @@ padding: 48rpx; } .modal-content { + position: relative; width: 100%; - max-width: 600rpx; + max-width: 560rpx; background: #1c1c1e; border-radius: 32rpx; overflow: hidden; @@ -26,6 +27,10 @@ right: 24rpx; z-index: 1; padding: 16rpx; + margin: 0; + background: transparent; + border: none; + line-height: 0; } .login-modal { padding: 48rpx 32rpx; @@ -42,18 +47,30 @@ color: #ffffff; display: block; margin-bottom: 16rpx; + text-shadow: none; + -webkit-text-fill-color: #ffffff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .login-desc { font-size: 26rpx; color: rgba(255, 255, 255, 0.5); display: block; - margin-bottom: 48rpx; + margin-bottom: 32rpx; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .btn-login { display: flex; align-items: center; justify-content: center; gap: 16rpx; + width: 100%; + box-sizing: border-box; padding: 28rpx; background: linear-gradient(135deg, #00CED1, #00B4D8); color: #ffffff; @@ -62,19 +79,31 @@ border-radius: 24rpx; margin-bottom: 20rpx; border: none; + line-height: 1.2; } .btn-login::after { border: none; } .btn-login-icon { - font-size: 36rpx; - line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.btn-login-disabled { opacity: 0.55; } +.btn-login[disabled] { + opacity: 0.55; } -.btn-login-disabled { opacity: 0.6; } .login-modal-cancel { - margin-top: 24rpx; - padding: 24rpx; + display: block; + width: 100%; + margin-top: 8rpx; + padding: 20rpx 24rpx 8rpx; font-size: 28rpx; color: rgba(255, 255, 255, 0.5); text-align: center; + background: transparent; + border: none; + box-sizing: border-box; + cursor: pointer; } .privacy-wechat-row { margin: 24rpx 0; @@ -101,11 +130,16 @@ .login-agree-row { display: flex; flex-wrap: wrap; - align-items: center; - justify-content: center; - margin-top: 32rpx; + align-items: flex-start; + justify-content: flex-start; + width: 100%; + box-sizing: border-box; + margin-top: 0; + margin-bottom: 32rpx; + padding: 0 8rpx; font-size: 22rpx; color: rgba(255, 255, 255, 0.5); + text-align: left; } .agree-checkbox { width: 32rpx; @@ -128,6 +162,11 @@ color: #00CED1; text-decoration: underline; padding: 0 4rpx; + margin: 0; + background: transparent; + border: none; + font-size: inherit; + line-height: inherit; } /* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */ diff --git a/reactH5/src/components/LoginModal/LoginModal.form.css b/reactH5/src/components/LoginModal/LoginModal.form.css new file mode 100644 index 00000000..c4c41b35 --- /dev/null +++ b/reactH5/src/components/LoginModal/LoginModal.form.css @@ -0,0 +1,78 @@ +/* H5 登录弹窗内嵌表单(小程序无此块) */ +.login-modal-fields { + width: 100%; + text-align: left; + margin-bottom: 24rpx; +} +.login-modal-field { + margin-bottom: 20rpx; +} +.login-modal-field:last-of-type { + margin-bottom: 8rpx; +} +.login-modal-label { + display: block; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 10rpx; +} +.login-modal-input-wrap { + padding: 14rpx 20rpx; + background: #1f2937; + border: 2rpx solid rgba(255, 255, 255, 0.08); + border-radius: 16rpx; + box-sizing: border-box; +} +.login-modal-input { + width: 100%; + font-size: 28rpx; + background: transparent; + border: none; + color: #ffffff; + outline: none; +} +.login-modal-input::placeholder { + color: rgba(255, 255, 255, 0.28); +} +.login-modal-forgot { + display: block; + width: 100%; + text-align: right; + font-size: 24rpx; + color: #00ced1; + text-decoration: underline; + background: none; + border: none; + padding: 8rpx 0 16rpx; + cursor: pointer; + margin: 0; +} +.login-modal-scroll { + max-height: min(72vh, 520px); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.login-modal-forgot-spacer { + height: 8rpx; +} + +.login-modal-field-hint { + margin: 8rpx 0 0; + font-size: 22rpx; + color: #ffb020; +} + +.login-modal-switch { + display: block; + width: 100%; + margin-top: 16rpx; + padding: 12rpx 0 8rpx; + text-align: center; + font-size: 26rpx; + color: #00ced1; + text-decoration: underline; + background: none; + border: none; + cursor: pointer; +} diff --git a/reactH5/src/components/LoginModal/LoginModal.tsx b/reactH5/src/components/LoginModal/LoginModal.tsx deleted file mode 100644 index 2ba9b313..00000000 --- a/reactH5/src/components/LoginModal/LoginModal.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { Icon } from '@/components/Icon/Icon' -import '@/components/LoginModal/LoginModal.css' - -type Props = { - show: boolean - desc: string - showCancel?: boolean - onClose: () => void - onSuccess?: () => void -} - -/** H5:手机号登录;布局与 miniprogram login-modal 一致(协议勾选、协议/隐私链) */ -export function LoginModal({ show, desc, showCancel = true, onClose, onSuccess }: Props) { - const nav = useNavigate() - const location = useLocation() - const [agreeProtocol, setAgreeProtocol] = useState(false) - - useEffect(() => { - if (show) setAgreeProtocol(false) - }, [show]) - - if (!show) return null - - const goLogin = () => { - onClose() - const returnTo = `${location.pathname}${location.search || ''}` - const safeReturn = - returnTo.startsWith('/login') || returnTo.startsWith('/dev-login') ? '/' : returnTo - nav('/login', { state: { onSuccess, returnTo: safeReturn } }) - } - - return ( -
-
e.stopPropagation()}> - -
- -
-
登录 卡若创业派对
-
{desc}
- - {showCancel ? ( -
- 取消 -
- ) : null} -
setAgreeProtocol((v) => !v)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setAgreeProtocol((v) => !v) - } - }} - role="button" - tabIndex={0} - > -
- {agreeProtocol ? : null} -
- 我已阅读并同意 - { - e.stopPropagation() - nav('/agreement') - }} - role="link" - tabIndex={-1} - > - 《用户协议》 - - - { - e.stopPropagation() - nav('/privacy') - }} - role="link" - tabIndex={-1} - > - 《隐私政策》 - -
-
-
- ) -} diff --git a/reactH5/src/components/PhoneAuthPanel/PhoneAuthPanel.tsx b/reactH5/src/components/PhoneAuthPanel/PhoneAuthPanel.tsx new file mode 100644 index 00000000..1b3181f5 --- /dev/null +++ b/reactH5/src/components/PhoneAuthPanel/PhoneAuthPanel.tsx @@ -0,0 +1,325 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { request } from '@/api/request' +import { useApp } from '@/context/AppContext' +import { useToast } from '@/context/ToastContext' +import type { MpUser } from '@/context/AppContext' + +import { Icon } from '@/components/Icon/Icon' + +import '@/components/LoginModal/LoginModal.css' +import '@/components/LoginModal/LoginModal.form.css' +import '@/components/PhoneAuthPanel/phone-auth-panel.css' + +type Panel = 'login' | 'register' + +type Props = { + desc: string + /** 忘记密码成功后应回到的路径(通常为进入登录前的页面) */ + forgotReturnTo: string + /** 已成功写入登录态,由页面负责 navigate */ + onAuthenticated: () => void + showCancel?: boolean + onCancel?: () => void +} + +/** + * 手机号 + 密码登录 / 注册(H5),与小程序组件视觉一致。 + * 仅用全页承载,不再使用弹窗以避免小屏溢出与键盘遮挡。 + */ +export function PhoneAuthPanel({ + desc, + forgotReturnTo, + onAuthenticated, + showCancel = false, + onCancel, +}: Props) { + const nav = useNavigate() + const { showToast } = useToast() + const { loginWithUser } = useApp() + + const [panel, setPanel] = useState('login') + const [agreeProtocol, setAgreeProtocol] = useState(false) + const [phone, setPhone] = useState('') + const [password, setPassword] = useState('') + const [password2, setPassword2] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + setPanel('login') + setAgreeProtocol(false) + setPhone('') + setPassword('') + setPassword2('') + setLoading(false) + }, []) + + const phoneOk = /^1[3-9]\d{9}$/.test(phone.replace(/\s/g, '')) + const pwdOk = password.length >= 6 + const pwdMatch = password === password2 && password2.length >= 6 + const canLogin = agreeProtocol && phoneOk && pwdOk && !loading + const canRegister = agreeProtocol && phoneOk && pwdMatch && !loading + + const goForgot = () => { + nav('/forgot-password', { + state: { returnTo: forgotReturnTo }, + }) + } + + const applyAuthSuccess = (res: { + success: boolean + data?: { user: MpUser; token: string; openId?: string } + }) => { + if (!res.success || !res.data) return + const { user, token, openId } = res.data as { + user: MpUser + token: string + openId?: string + } + const merged: MpUser = { + ...user, + openId: String(openId || user.openId || '').trim() || user.id, + } + loginWithUser(merged, token) + onAuthenticated() + } + + const doLogin = async () => { + if (!canLogin) return + const p = phone.replace(/\s/g, '') + setLoading(true) + try { + const res = await request<{ + success: boolean + data?: { user: MpUser; token: string; openId?: string } + }>('/api/miniprogram/h5/login-by-phone', { + method: 'POST', + data: { phone: p, password }, + }) + if (res.success && res.data) { + showToast('登录成功') + applyAuthSuccess(res) + } + } catch { + /* request 已 toast */ + } finally { + setLoading(false) + } + } + + const doRegister = async () => { + if (!canRegister) return + if (password !== password2) { + showToast('两次密码不一致') + return + } + const p = phone.replace(/\s/g, '') + setLoading(true) + try { + const res = await request<{ + success: boolean + data?: { user: MpUser; token: string; openId?: string } + }>('/api/miniprogram/h5/register-by-phone', { + method: 'POST', + data: { phone: p, password }, + }) + if (res.success && res.data) { + showToast('注册成功') + applyAuthSuccess(res) + } + } catch { + /* request 已 toast */ + } finally { + setLoading(false) + } + } + + const switchToLogin = () => { + setPanel('login') + setPassword('') + setPassword2('') + } + + const switchToRegister = () => { + setPanel('register') + setPassword('') + setPassword2('') + } + + return ( +
+
+ +
+
{panel === 'login' ? '登录 卡若创业派对' : '注册 卡若创业派对'}
+
{desc}
+ +
setAgreeProtocol((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setAgreeProtocol((v) => !v) + } + }} + role="button" + tabIndex={0} + aria-pressed={agreeProtocol} + aria-label="同意用户协议与隐私政策" + > +
+ {agreeProtocol ? : null} +
+ 我已阅读并同意 + { + e.stopPropagation() + nav('/agreement') + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + e.preventDefault() + nav('/agreement') + } + }} + role="link" + tabIndex={0} + > + 《用户协议》 + + + { + e.stopPropagation() + nav('/privacy') + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + e.preventDefault() + nav('/privacy') + } + }} + role="link" + tabIndex={0} + > + 《隐私政策》 + +
+ +
+
+ +
+ setPhone((e.target.value || '').replace(/\D/g, '').slice(0, 11))} + /> +
+
+
+ +
+ setPassword(e.target.value || '')} + /> +
+
+ + {panel === 'register' ? ( +
+ +
+ setPassword2(e.target.value || '')} + /> +
+ {password2.length > 0 && password !== password2 ? ( +

两次输入不一致

+ ) : null} +
+ ) : null} + + {panel === 'login' ? ( + + ) : ( +
+ )} +
+ + {panel === 'login' ? ( + + ) : ( + + )} + + {panel === 'login' ? ( + + ) : ( + + )} + + {showCancel && typeof onCancel === 'function' ? ( + + ) : null} +
+ ) +} diff --git a/reactH5/src/components/PhoneAuthPanel/phone-auth-panel.css b/reactH5/src/components/PhoneAuthPanel/phone-auth-panel.css new file mode 100644 index 00000000..009edb49 --- /dev/null +++ b/reactH5/src/components/PhoneAuthPanel/phone-auth-panel.css @@ -0,0 +1,13 @@ +/* 面板宽度交给页面容器(DevLogin);不在此限死 560rpx,以免 H5 全页过窄 */ +.phone-auth-panel { + margin: 0; + width: 100%; + max-width: none; + box-sizing: border-box; +} +.phone-auth-desc { + white-space: normal !important; + text-overflow: clip !important; + overflow: visible !important; + line-height: 1.45; +} diff --git a/reactH5/src/constants/h5Auth.ts b/reactH5/src/constants/h5Auth.ts new file mode 100644 index 00000000..5bbc125d --- /dev/null +++ b/reactH5/src/constants/h5Auth.ts @@ -0,0 +1,4 @@ +/** 首页登录后继续动作(sessionStorage) */ +export const H5_INDEX_AFTER_LOGIN_KEY = 'h5_index_after_login' +/** 刚从登录页回到首页(与上面配合,避免误触发) */ +export const H5_LOGIN_JUST_SUCCEEDED_KEY = 'h5_login_just_succeeded' diff --git a/reactH5/src/context/AppContext.tsx b/reactH5/src/context/AppContext.tsx index b7e56da7..e924ef57 100644 --- a/reactH5/src/context/AppContext.tsx +++ b/reactH5/src/context/AppContext.tsx @@ -11,6 +11,12 @@ import { bindRequestUi, request } from '@/api/request' import { fetchAuditMode, fetchMiniprogramConfig, setMemoryConfig, type MergedConfig } from '@/api/getConfig' import { storage, clearAuthStorage } from '@/api/storage' import { useToast } from '@/context/ToastContext' +import { + getBrowseDistinctChapterCount, + loadReadSectionIds, + persistMarkSectionRead, + touchRecentSection as touchRecentSectionStorage, +} from '@/utils/readStorage' export type MpUser = { id: string @@ -29,7 +35,7 @@ export type MpUser = { wechat_id?: string } -type AppGlobal = { +export type AppGlobal = { baseUrl: string userInfo: MpUser | null openId: string | null @@ -53,8 +59,13 @@ type Ctx = { loginWithUser: (user: MpUser, token: string) => void getReadCount: () => number getTotalSections: () => number + markSectionAsRead: (sectionId: string) => void + touchRecentSection: (sectionId: string) => void + getBrowseDistinctChapterCount: () => number } +const AppCtx = createContext(null) + const defaultGlobal = (): AppGlobal => ({ baseUrl: '', userInfo: null, @@ -71,18 +82,6 @@ const defaultGlobal = (): AppGlobal => ({ configCache: null, }) -function loadReadSectionIds(): string[] { - try { - const raw = localStorage.getItem('readSectionIds') - if (!raw) return [] - const p = JSON.parse(raw) as unknown - return Array.isArray(p) ? (p as string[]) : [] - } catch { - return [] - } -} - -const AppCtx = createContext(null) export function AppProvider({ children }: { children: ReactNode }) { const { showToast } = useToast() @@ -138,6 +137,17 @@ export function AppProvider({ children }: { children: ReactNode }) { })) }, []) + const markSectionAsRead = useCallback((sectionId: string) => { + persistMarkSectionRead(sectionId) + setGlobal((g) => ({ ...g, readSectionIds: loadReadSectionIds() })) + }, [setGlobal]) + + const touchRecentSection = useCallback((sectionId: string) => { + touchRecentSectionStorage(sectionId) + }, []) + + const browseDistinct = useCallback(() => getBrowseDistinctChapterCount(), []) + const getReadCount = useCallback(() => global.readSectionIds.length, [global.readSectionIds]) const getTotalSections = useCallback(() => global.totalSections, [global.totalSections]) @@ -246,8 +256,22 @@ export function AppProvider({ children }: { children: ReactNode }) { loginWithUser, getReadCount, getTotalSections, + markSectionAsRead, + touchRecentSection, + getBrowseDistinctChapterCount: browseDistinct, }), - [global, setGlobal, refreshConfig, logout, loginWithUser, getReadCount, getTotalSections] + [ + global, + setGlobal, + refreshConfig, + logout, + loginWithUser, + getReadCount, + getTotalSections, + markSectionAsRead, + touchRecentSection, + browseDistinct, + ] ) return {children} diff --git a/reactH5/src/pages/Chapters/ChaptersPage.css b/reactH5/src/pages/Chapters/ChaptersPage.css index dbfbff39..cc6a20f2 100644 --- a/reactH5/src/pages/Chapters/ChaptersPage.css +++ b/reactH5/src/pages/Chapters/ChaptersPage.css @@ -453,6 +453,9 @@ font-size: 20rpx; color: rgba(255, 255, 255, 0.35); margin-top: 4rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .part-right { @@ -650,39 +653,156 @@ } -/* ----- H5:目录页 scoped 兜底,避免与首页/匹配页同名类互相覆盖导致样式错乱 ----- */ +/* ----- H5:目录页 scoped 兜底(与 chapters.wxss 一致),避免首页 Index 同名 .part-* 覆盖宽高 ----- */ .chapters-page.page { padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); box-sizing: border-box; } .chapters-page .chapters-content { - padding: 16rpx 24rpx; + /* 与导航栏 nav-content 左右 32rpx 对齐,卡片区略收窄(参考小程序视觉边距) */ + padding: 16rpx 32rpx; width: 100%; box-sizing: border-box; } +.chapters-page .book-card-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6rpx; +} .chapters-page .book-card-title { font-size: 30rpx; font-weight: 700; color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 1rpx; +} +.chapters-page .book-card-subtitle { + font-size: 22rpx; + color: rgba(255, 255, 255, 0.35); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chapters-page .part-item { + /* 盖住全局 IndexPage.css .part-item(padding/背景/边框),只保留外层容器与小程序一致 */ + display: block; + width: 100%; + margin-bottom: 12rpx; + padding: 0; + gap: 0; + align-items: unset; + justify-content: unset; + flex-wrap: unset; + background: transparent; + border: none; + border-radius: 0; + box-sizing: border-box; +} +.chapters-page .part-item:active { + transform: none; + background: transparent; +} +.chapters-page .part-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22rpx 24rpx; + background: rgba(28, 28, 30, 0.7); + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.04); + width: 100%; + box-sizing: border-box; +} +.chapters-page .part-left { + display: flex; + align-items: center; + gap: 20rpx; + flex: 1; + min-width: 0; +} +.chapters-page .part-right { + display: flex; + align-items: center; + gap: 12rpx; + flex-shrink: 0; + white-space: nowrap; +} +.chapters-page .part-icon { + width: 56rpx; + height: 56rpx; + min-width: 56rpx; + min-height: 56rpx; + border-radius: 14rpx; + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + font-weight: 700; + color: #ffffff; + flex-shrink: 0; +} +.chapters-page .part-icon-emoji { + font-size: 28rpx; + font-weight: 400; + line-height: 1; + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.25); +} +.chapters-page .part-icon-img { + width: 56rpx; + height: 56rpx; + border-radius: 14rpx; + flex-shrink: 0; +} +.chapters-page .part-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; } .chapters-page .part-title { font-size: 27rpx; font-weight: 600; color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + margin-bottom: 0; +} +.chapters-page .part-subtitle { + font-size: 20rpx; + color: rgba(255, 255, 255, 0.35); + margin-top: 4rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} +.chapters-page .part-count { + font-size: 22rpx; + color: rgba(255, 255, 255, 0.35); +} +.chapters-page .part-arrow { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.4); } .chapters-page .section-title { font-size: 25rpx; color: #ffffff; - white-space: normal; - word-break: break-word; - overflow-wrap: anywhere; - line-height: 1.35; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; } .chapters-page .tag { display: inline-flex; @@ -694,6 +814,7 @@ border-radius: 8rpx; box-sizing: border-box; text-align: center; + flex-shrink: 0; } .chapters-page .tag-free { background: rgba(0, 206, 209, 0.1); @@ -703,39 +824,16 @@ display: flex; width: 100%; box-sizing: border-box; - align-items: flex-start; + align-items: center; } .chapters-page .chapters-list { margin-top: 12rpx; margin-left: 12rpx; } -.chapters-page .part-item { - display: block; - width: 100%; - margin-bottom: 12rpx; -} .chapters-page .part-item > .chapters-list { display: block; width: 100%; } -.chapters-page .part-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - column-gap: 12rpx; - width: 100%; - box-sizing: border-box; -} -.chapters-page .part-left { - min-width: 0; -} -.chapters-page .part-right { - justify-self: end; - white-space: nowrap; - display: inline-flex; - align-items: center; - gap: 12rpx; -} .chapters-page .section-list { display: block; width: 100%; @@ -752,7 +850,7 @@ .chapters-page .section-left { display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; gap: 14rpx; flex: 1; min-width: 0; @@ -763,7 +861,6 @@ gap: 12rpx; flex-shrink: 0; margin-left: 12rpx; - padding-top: 2rpx; } .chapters-page .card { background: rgba(28, 28, 30, 0.7); diff --git a/reactH5/src/pages/DevLogin/DevLoginPage.css b/reactH5/src/pages/DevLogin/DevLoginPage.css index 73197d08..3fdb19c7 100644 --- a/reactH5/src/pages/DevLogin/DevLoginPage.css +++ b/reactH5/src/pages/DevLogin/DevLoginPage.css @@ -15,10 +15,214 @@ .form-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; border: 2rpx solid rgba(0,206,209,0.2); } .form-item { margin-bottom: 32rpx; } -.form-item:last-of-type { margin-bottom: 48rpx; } +.form-item:last-of-type { margin-bottom: 24rpx; } +.login-forgot-link { + display: block; + width: 100%; + text-align: right; + font-size: 26rpx; + color: #00ced1; + text-decoration: underline; + margin: 0 0 32rpx; + background: transparent; + border: none; + padding: 0; +} .form-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; margin-bottom: 16rpx; } .form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; } .form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; } .input-placeholder { color: rgba(255,255,255,0.25); } .btn-primary { padding: 32rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #000; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 28rpx; } .btn-disabled { opacity: 0.5; } + +/* 全页登录(原弹窗迁至此处,可滚动 + 安全区) */ +.phone-auth-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: #000; + /* 提示浏览器使用暗色控件,减少系统默认浅色 input */ + color-scheme: dark; +} + +.phone-auth-scroll { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + width: 100%; + max-width: min(520px, 100vw); + margin: 0 auto; + box-sizing: border-box; + padding: 36rpx 16rpx 56rpx; + padding-bottom: calc(56rpx + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: column; + align-items: stretch; +} + +/* —— 全页登录:暗色一体输入 + 层级(仅本页) —— */ + +.phone-auth-page .phone-auth-panel { + width: 100%; + padding: 8rpx 0 0; + border-radius: 28rpx; + background: linear-gradient(165deg, rgba(40, 42, 46, 0.55) 0%, rgba(22, 23, 26, 0.92) 48%, rgba(14, 14, 16, 0.98) 100%); + border: 1rpx solid rgba(255, 255, 255, 0.07); + box-shadow: 0 24rpx 64rpx rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-sizing: border-box; +} + +.phone-auth-page .login-modal { + padding: 36rpx 20rpx 48rpx; + box-sizing: border-box; +} + +.phone-auth-page .login-icon { + margin-bottom: 28rpx; + filter: drop-shadow(0 0 20rpx rgba(0, 206, 209, 0.25)); +} + +.phone-auth-page .login-title { + font-size: 40rpx; + font-weight: 800; + letter-spacing: 0.02em; + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.25; + margin-bottom: 18rpx; + color: rgba(255, 255, 255, 0.98); +} + +.phone-auth-page .login-desc, +.phone-auth-page .phone-auth-desc { + font-size: 27rpx; + line-height: 1.58; + margin-bottom: 36rpx; + padding: 0 4rpx; + color: rgba(255, 255, 255, 0.48); + font-weight: 400; +} + +.phone-auth-page .login-agree-row { + margin-bottom: 38rpx; + padding: 24rpx 20rpx; + font-size: 24rpx; + line-height: 1.55; + background: rgba(0, 0, 0, 0.35); + border-radius: 22rpx; + border: 1rpx solid rgba(0, 206, 209, 0.12); + box-sizing: border-box; +} + +.phone-auth-page .login-modal-fields { + margin-bottom: 32rpx; +} + +.phone-auth-page .login-modal-field { + margin-bottom: 28rpx; +} + +.phone-auth-page .login-modal-field:last-of-type { + margin-bottom: 10rpx; +} + +.phone-auth-page .login-modal-label { + font-size: 26rpx; + font-weight: 500; + margin-bottom: 12rpx; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.02em; +} + +.phone-auth-page .login-modal-input-wrap { + display: flex; + align-items: center; + min-height: 96rpx; + padding: 18rpx 24rpx; + border-radius: 22rpx; + background: rgba(12, 14, 18, 0.95); + border: 1rpx solid rgba(255, 255, 255, 0.1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.35); + box-sizing: border-box; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.phone-auth-page .login-modal-input-wrap:focus-within { + border-color: rgba(0, 206, 209, 0.5); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.35), + 0 0 0 1rpx rgba(0, 206, 209, 0.22); +} + +.phone-auth-page .login-modal-input { + flex: 1; + min-width: 0; + width: 100%; + font-size: 30rpx; + font-weight: 500; + line-height: 1.45; + letter-spacing: 0.03em; + /* _kill 系统默认浅色块与灰边 */ + -webkit-appearance: none; + appearance: none; + background: transparent !important; + background-color: transparent !important; + color: rgba(255, 255, 255, 0.96) !important; + border: none !important; + outline: none !important; + box-shadow: none !important; + border-radius: 0 !important; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.phone-auth-page .login-modal-input::placeholder { + color: rgba(255, 255, 255, 0.32); +} + +.phone-auth-page .login-modal-input:focus { + outline: none !important; +} + +/* Chrome / Safari autofill:避免整块刷成浅黄/浅色 */ +.phone-auth-page .login-modal-input:-webkit-autofill, +.phone-auth-page .login-modal-input:-webkit-autofill:hover, +.phone-auth-page .login-modal-input:-webkit-autofill:focus, +.phone-auth-page .login-modal-input:-webkit-autofill:active { + -webkit-text-fill-color: rgba(255, 255, 255, 0.96) !important; + caret-color: var(--app-brand, #00ced1); + transition: background-color 99999s ease-out 0s; + box-shadow: 0 0 0 1000px rgba(12, 14, 18, 0.98) inset !important; +} + +.phone-auth-page .login-modal-forgot { + padding: 12rpx 4rpx 20rpx; + font-size: 26rpx; +} + +.phone-auth-page .btn-login { + padding: 32rpx 28rpx; + font-size: 32rpx; + font-weight: 700; + border-radius: 24rpx; + margin-bottom: 24rpx; + letter-spacing: 0.06em; + box-shadow: 0 14rpx 40rpx rgba(0, 206, 209, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.22); +} + +.phone-auth-page .btn-login[disabled] { + box-shadow: none; +} + +.phone-auth-page .login-modal-switch { + margin-top: 4rpx; + padding: 18rpx 0 14rpx; + font-size: 28rpx; + opacity: 0.95; +} + +.phone-auth-page .login-modal-cancel { + padding-top: 12rpx; +} diff --git a/reactH5/src/pages/DevLogin/DevLoginPage.tsx b/reactH5/src/pages/DevLogin/DevLoginPage.tsx index 0606ede8..d89e0d8e 100644 --- a/reactH5/src/pages/DevLogin/DevLoginPage.tsx +++ b/reactH5/src/pages/DevLogin/DevLoginPage.tsx @@ -1,118 +1,74 @@ -import { useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { request } from '@/api/request' -import { useApp } from '@/context/AppContext' -import { useToast } from '@/context/ToastContext' -import type { MpUser } from '@/context/AppContext' +import { useNavigate, useLocation } from 'react-router-dom' + import { Icon } from '@/components/Icon/Icon' +import { PhoneAuthPanel } from '@/components/PhoneAuthPanel/PhoneAuthPanel' + +import { H5_INDEX_AFTER_LOGIN_KEY, H5_LOGIN_JUST_SUCCEEDED_KEY } from '@/constants/h5Auth' + import '@/pages/DevLogin/DevLoginPage.css' const statusBar = 44 +type LocState = { + onSuccess?: () => void + returnTo?: string + desc?: string +} + export function DevLoginPage() { const nav = useNavigate() - const loc = useLocation() as { state?: { onSuccess?: () => void; returnTo?: string } } + const loc = useLocation() const resolveAfterLoginPath = (raw?: string) => { if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/' if (raw.includes('..') || raw.startsWith('/dev-login') || raw.startsWith('/login')) return '/' return raw || '/' } - const { showToast } = useToast() - const { loginWithUser } = useApp() - const [account, setAccount] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) + + const state = (loc.state || {}) as LocState + const returnTo = resolveAfterLoginPath(state.returnTo) + const desc = + typeof state.desc === 'string' && state.desc.trim() + ? state.desc.trim() + : '登录后可购买章节、解锁更多内容' + + const onBack = () => { + try { + sessionStorage.removeItem(H5_INDEX_AFTER_LOGIN_KEY) + } catch { + /* */ + } + nav(-1) + } + + const afterAuth = () => { + const cb = state.onSuccess + if (typeof cb === 'function') cb() + + if (returnTo === '/' || returnTo.startsWith('/?')) { + try { + sessionStorage.setItem(H5_LOGIN_JUST_SUCCEEDED_KEY, '1') + } catch { + /* */ + } + } + + window.setTimeout(() => nav(returnTo, { replace: true }), 200) + } return ( -
+
- -
手机号登录
+
登录 / 注册
-
-
- 请输入已在平台绑定的 11 位手机号。老用户与小程序为同一账号(按库内手机号匹配)。若曾设置密码可填写,未设置可留空。 -
- -
-
-
手机号
-
- setAccount((e.target.value || '').trim())} - maxLength={11} - /> -
-
-
-
密码(可留空)
-
- setPassword(e.target.value || '')} - /> -
-
- -
+
+
) diff --git a/reactH5/src/pages/ForgotPassword/ForgotPasswordPage.tsx b/reactH5/src/pages/ForgotPassword/ForgotPasswordPage.tsx new file mode 100644 index 00000000..05cffa3d --- /dev/null +++ b/reactH5/src/pages/ForgotPassword/ForgotPasswordPage.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { request } from '@/api/request' +import { useToast } from '@/context/ToastContext' +import { Icon } from '@/components/Icon/Icon' +import '@/pages/DevLogin/DevLoginPage.css' + +const statusBar = 44 + +export function ForgotPasswordPage() { + const nav = useNavigate() + const loc = useLocation() as { state?: { returnTo?: string } } + const { showToast } = useToast() + const [phone, setPhone] = useState('') + const [pwd, setPwd] = useState('') + const [pwd2, setPwd2] = useState('') + const [loading, setLoading] = useState(false) + + const submit = async () => { + const p = phone.replace(/\s/g, '') + if (p.length < 11) { + showToast('请输入11位手机号') + return + } + if (pwd.length < 6) { + showToast('新密码至少6位') + return + } + if (pwd !== pwd2) { + showToast('两次密码不一致') + return + } + setLoading(true) + try { + const res = await request<{ success?: boolean; message?: string }>({ + url: '/api/miniprogram/h5/reset-password', + method: 'POST', + data: { phone: p, newPassword: pwd }, + }) + if (res?.success) { + showToast(res.message || '已提交') + const rt = + typeof loc.state?.returnTo === 'string' && loc.state.returnTo.startsWith('/') ? loc.state.returnTo : '/' + setTimeout(() => nav('/login', { replace: true, state: { returnTo: rt } }), 500) + } + } catch { + /* request 已 toast */ + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
忘记密码
+
+
+
+ +
+
+ + 将为此手机号重置 H5 登录密码(与小程序同一账号)。无需短信验证,请勿在公共网络使用;若未开启 H5 登录,请联系管理员。 + +
+ +
+
+
手机号
+
+ setPhone((e.target.value || '').trim())} + maxLength={11} + /> +
+
+
+
新密码(至少6位)
+
+ setPwd(e.target.value || '')} + /> +
+
+
+
确认新密码
+
+ setPwd2(e.target.value || '')} + /> +
+
+ +
+
+
+ ) +} diff --git a/reactH5/src/pages/Index/IndexPage.tsx b/reactH5/src/pages/Index/IndexPage.tsx index 53a3b228..13798ada 100644 --- a/reactH5/src/pages/Index/IndexPage.tsx +++ b/reactH5/src/pages/Index/IndexPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' import { request } from '@/api/request' import { useApp } from '@/context/AppContext' import { useToast } from '@/context/ToastContext' @@ -7,7 +7,7 @@ import { trackClick } from '@/utils/trackClick' import { cleanSingleLineField } from '@/utils/contentParser' import { isSafeImageSrc } from '@/utils/imageUrl' import { Icon } from '@/components/Icon/Icon' -import { LoginModal } from '@/components/LoginModal/LoginModal' +import { H5_INDEX_AFTER_LOGIN_KEY, H5_LOGIN_JUST_SUCCEEDED_KEY } from '@/constants/h5Auth' import { submitCkbLeadH5 } from '@/utils/soulBridge' import { DEFAULT_KARUO_LINK_AVATAR, @@ -44,6 +44,7 @@ function normalizeSectionTitle(s: Record): string { export function IndexPage() { const nav = useNavigate() + const location = useLocation() const { showToast } = useToast() const { global, setGlobal, refreshConfig, getReadCount, getTotalSections } = useApp() @@ -114,14 +115,30 @@ export function IndexPage() { const [tipModalSubtitle, setTipModalSubtitle] = useState('') const [micHorizontalSlots, setMicHorizontalSlots] = useState([]) const [micDefaultGiftId, setMicDefaultGiftId] = useState('') - const [showLoginModal, setShowLoginModal] = useState(false) - const [loginModalDesc, setLoginModalDesc] = useState('登录后可参与打赏、上麦与链接嘉宾') - const [pendingAfterLogin, setPendingAfterLogin] = useState(null) + const openTipModalRef = useRef<(tipSource: 'live_mic' | 'home_reward') => void>(() => {}) + const onHeaderCornerTapRef = useRef<() => void>(() => {}) const updateUserStatus = useCallback(() => { void Math.min(getReadCount(), totalSections || getTotalSections()) }, [getReadCount, getTotalSections, totalSections]) + const goToLoginPage = useCallback( + ( + desc: string, + indexAfter?: { type: 'tip'; tipSource: string } | { type: 'link_karuo' } + ) => { + if (indexAfter) { + try { + sessionStorage.setItem(H5_INDEX_AFTER_LOGIN_KEY, JSON.stringify(indexAfter)) + } catch { + /* */ + } + } + nav('/login', { state: { returnTo: '/', desc } }) + }, + [nav] + ) + const syncHeaderCorner = useCallback( ( audit: boolean, @@ -495,14 +512,60 @@ export function IndexPage() { updateUserStatus() }, [updateUserStatus, global.isLoggedIn, totalSections]) + useEffect(() => { + if (!global.isLoggedIn || location.pathname !== '/') return + + let justOk = false + try { + justOk = sessionStorage.getItem(H5_LOGIN_JUST_SUCCEEDED_KEY) === '1' + } catch { + return + } + if (!justOk) return + try { + sessionStorage.removeItem(H5_LOGIN_JUST_SUCCEEDED_KEY) + } catch { + /* */ + } + + let raw: string | null = null + try { + raw = sessionStorage.getItem(H5_INDEX_AFTER_LOGIN_KEY) + } catch { + /* */ + } + + updateUserStatus() + + if (!raw) return + try { + sessionStorage.removeItem(H5_INDEX_AFTER_LOGIN_KEY) + } catch { + /* */ + } + + try { + const p = JSON.parse(raw) as { type?: string; tipSource?: string } + if (p?.type === 'tip' && p.tipSource) { + window.setTimeout( + () => openTipModalRef.current(p.tipSource as 'live_mic' | 'home_reward'), + 0 + ) + } else if (p?.type === 'link_karuo') { + window.setTimeout(() => void onHeaderCornerTapRef.current(), 0) + } + } catch { + /* */ + } + }, [global.isLoggedIn, location.pathname, updateUserStatus]) + const openTipModal = (tipSource: 'live_mic' | 'home_reward') => { if (global.auditMode) return if (!global.isLoggedIn) { - setPendingAfterLogin({ type: 'tip', tipSource }) - setLoginModalDesc( - tipSource === 'live_mic' ? '登录后可申请上麦并完成礼遇支付' : '登录后可打赏支持' + goToLoginPage( + tipSource === 'live_mic' ? '登录后可申请上麦并完成礼遇支付' : '登录后可打赏支持', + { type: 'tip', tipSource } ) - setShowLoginModal(true) return } const sch = liveMicSchedule @@ -529,9 +592,7 @@ export function IndexPage() { void (async () => { if (!homePinnedPerson?.token) return if (!global.isLoggedIn) { - setPendingAfterLogin({ type: 'link_karuo' }) - setLoginModalDesc('登录后可向嘉宾留下联系方式') - setShowLoginModal(true) + goToLoginPage('登录后可向嘉宾留下联系方式', { type: 'link_karuo' }) return } await submitCkbLeadH5( @@ -550,6 +611,9 @@ export function IndexPage() { openTipModal(headerCornerAction.kind === 'mic' ? 'live_mic' : 'home_reward') } + openTipModalRef.current = openTipModal + onHeaderCornerTapRef.current = onHeaderCornerTap + return (
@@ -875,27 +939,6 @@ export function IndexPage() {
)} - { - setShowLoginModal(false) - setPendingAfterLogin(null) - }} - onSuccess={() => { - setShowLoginModal(false) - const p = pendingAfterLogin - setPendingAfterLogin(null) - updateUserStatus() - if (p?.type === 'tip' && p.tipSource) { - window.setTimeout(() => openTipModal(p.tipSource as 'live_mic' | 'home_reward'), 0) - } else if (p?.type === 'link_karuo') { - window.setTimeout(() => onHeaderCornerTap(), 0) - } - }} - /> -
) diff --git a/reactH5/src/pages/My/MyPage.tsx b/reactH5/src/pages/My/MyPage.tsx index 4ce346d4..7d8f7e48 100644 --- a/reactH5/src/pages/My/MyPage.tsx +++ b/reactH5/src/pages/My/MyPage.tsx @@ -2,14 +2,13 @@ * 我的页 — 对齐 miniprogram/pages/my 布局与主数据流 */ import { useCallback, useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { request } from '@/api/request' import { storage } from '@/api/storage' import { useApp } from '@/context/AppContext' import type { MpUser } from '@/context/AppContext' import { useToast } from '@/context/ToastContext' import { Icon } from '@/components/Icon/Icon' -import { LoginModal } from '@/components/LoginModal/LoginModal' import { trackClick } from '@/utils/trackClick' import { formatStatNum } from '@/utils/util' import { cleanSingleLineField } from '@/utils/contentParser' @@ -30,10 +29,10 @@ function formatMbtiTagText(user: MpUser | null): string { export function MyPage() { const nav = useNavigate() + const location = useLocation() const { showToast } = useToast() const { global, setGlobal, getReadCount } = useApp() - const [showLoginModal, setShowLoginModal] = useState(false) const [userInfo, setUserInfo] = useState(global.userInfo) const [isLoggedIn, setIsLoggedIn] = useState(global.isLoggedIn) const [profileAvatarDisplay, setProfileAvatarDisplay] = useState('') @@ -350,7 +349,12 @@ export function MyPage() { const showLogin = () => { trackClick('my', 'btn_click', '点击登录') - setShowLoginModal(true) + nav('/login', { + state: { + returnTo: `${location.pathname}${location.search || ''}`, + desc: '登录后可购买章节、解锁更多内容', + }, + }) } const saveContactInfo = async () => { @@ -750,17 +754,6 @@ export function MyPage() {
) : null} - setShowLoginModal(false)} - onSuccess={() => { - setShowLoginModal(false) - initUserStatus() - }} - /> -
) diff --git a/reactH5/src/pages/Read/ReadPage.css b/reactH5/src/pages/Read/ReadPage.css new file mode 100644 index 00000000..c9832898 --- /dev/null +++ b/reactH5/src/pages/Read/ReadPage.css @@ -0,0 +1,764 @@ +/* 阅读页:对齐小程序 read.wxss 暗色主题,类名前缀 read- 避免与其它 .page 冲突 */ + +.read-page { + min-height: 100vh; + background: #000; + color: rgba(255, 255, 255, 0.88); + display: flex; + flex-direction: column; +} + +.read-progress-bar { + position: fixed; + left: 0; + right: 0; + height: 3px; + background: #1c1c1e; + z-index: 300; +} + +.read-progress-fill { + height: 100%; + background: linear-gradient(90deg, #00ced1 0%, #20b2aa 100%); + transition: width 0.15s ease; +} + +.read-nav-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.read-nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + height: 44px; +} + +.read-nav-back, +.read-nav-ph { + width: 44px; + flex-shrink: 0; +} + +.read-nav-back { + border-radius: 50%; + background: #1c1c1e; + border: none; + color: #fff; + font-size: 22px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.read-nav-info { + flex: 1; + text-align: center; + min-width: 0; +} + +.read-nav-chapter { + display: block; + font-size: 13px; + color: rgba(255, 255, 255, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.read-scroll { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.read-content-inner { + max-width: 720px; + margin: 0 auto; + padding: 24px 20px 120px; +} + +.read-select { + user-select: text; +} + +.read-inline-root { + display: inline; + white-space: pre-wrap; +} + +.read-chapter-header { + margin-bottom: 28px; +} + +.read-chapter-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.read-chapter-id { + font-size: 14px; + color: #00ced1; + background: rgba(0, 206, 209, 0.12); + padding: 4px 12px; + border-radius: 16px; +} + +.read-tag-read--free { + font-size: 12px; + color: #00ced1; + background: rgba(0, 206, 209, 0.1); + padding: 2px 8px; + border-radius: 6px; +} + +.read-chapter-title { + margin: 0; + font-size: 26px; + font-weight: 700; + line-height: 1.35; + color: #fff; +} + +.read-paragraph { + margin-bottom: 18px; + font-size: 17px; + line-height: 1.72; + color: rgba(255, 255, 255, 0.9); +} + +.read-seg-heading { + margin: 20px 0 12px; + font-weight: 700; + color: #fff; +} +.read-seg-h2 { font-size: 22px; } +.read-seg-h3 { font-size: 19px; } +.read-seg-h4 { font-size: 17px; } +.read-seg-h5, .read-seg-h6 { font-size: 16px; } + +.read-seg-quote { + padding: 12px 16px; + border-left: 3px solid rgba(0, 206, 209, 0.5); + background: rgba(255, 255, 255, 0.04); + margin-bottom: 18px; + font-size: 16px; + line-height: 1.66; +} + +.read-table-scroll { + overflow-x: auto; + margin-bottom: 18px; +} + +.read-content-table { + min-width: 100%; +} + +.read-table-row { + display: flex; +} + +.read-table-header-row .read-table-cell { + font-weight: 600; +} + +.read-table-cell { + flex: 1; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 14px; +} + +.read-table-header-cell { + background: rgba(0, 206, 209, 0.12); +} + +.read-seg-list-item { + margin-bottom: 12px; + display: flex; + gap: 8px; + align-items: flex-start; +} + +.read-seg-list-marker { + color: rgba(255, 255, 255, 0.45); + flex-shrink: 0; +} + +.read-seg-list-text { + font-size: 17px; + line-height: 1.72; +} + +.read-content-image { + max-width: 100%; + border-radius: 8px; + display: block; + margin-bottom: 8px; + cursor: pointer; +} + +.read-content-video-wrap { + margin-bottom: 18px; +} + +.read-content-video { + width: 100%; + max-height: 70vh; + border-radius: 8px; + background: #000; +} + +.read-mention { + color: #00ced1; + font-weight: 600; + cursor: pointer; + margin: 0 1px; +} + +.read-link-tag { + color: #5eead4; + font-weight: 600; + cursor: pointer; + margin: 0 1px; +} + +.read-article-link { + color: #93c5fd; + text-decoration: underline; + cursor: pointer; +} + +.read-preview-wrap { + position: relative; +} + +.read-preview-body { + position: relative; +} + +.read-fade-mask { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 120px; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.95)); + pointer-events: none; +} + +.read-paywall-card { + margin-top: 8px; + padding: 20px; + border-radius: 14px; + background: rgba(28, 28, 30, 0.85); +} + +.read-paywall-lock { + width: 52px; + height: 52px; + margin: 0 auto 14px; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, rgba(0, 206, 209, 0.35), #1c1c1e 70%); +} + +.read-paywall-market { + text-align: center; + margin-bottom: 14px; + font-size: 16px; + line-height: 1.55; +} + +.read-paywall-inline { + color: rgba(255, 255, 255, 0.85); +} + +.read-paywall-pct { + color: #ffb020; + font-weight: 800; +} + +.read-share-tip { + text-align: center; + margin-top: 16px; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.read-purchase-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 10px; +} + +.read-purchase-btn { + border: none; + border-radius: 12px; + padding: 14px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + font-size: 16px; +} + +.read-purchase-section--primary { + background: linear-gradient(135deg, #0891b2, #06b6d4); + color: #042f2e; +} + +.read-purchase-section--secondary { + background: transparent; + border: 1px solid rgba(0, 206, 209, 0.45); + color: #00ced1; +} + +.read-purchase-fullbook { + background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%); + color: #1c1708; +} + +.read-purchase-fullbook--secondary { + background: rgba(232, 198, 10, 0.15); + border: 1px solid rgba(234, 179, 8, 0.4); + color: #fcd34d; +} + +.read-login-btn { + margin-top: 12px; + width: 100%; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 12px; + color: #fff; + cursor: pointer; +} + +.read-login-btn-text { + font-size: 15px; +} + +.read-purchase-main { + width: 100%; + border: none; + border-radius: 12px; + padding: 14px; + background: linear-gradient(135deg, #0891b2, #06b6d4); + color: #052f37; + font-weight: 700; + cursor: pointer; +} + +.read-pay365-slot { + display: flex; + flex-direction: column; + gap: 12px; +} + +.read-pay365-anchor { + width: 100%; + padding: 12px 14px; + border-radius: 11px; + border: 1px solid rgba(0, 206, 209, 0.35); + background: rgba(0, 206, 209, 0.08); + color: rgba(255, 255, 255, 0.92); + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.read-pay365-badge { + flex-shrink: 0; + background: #eab308; + color: #1a1504; + font-size: 12px; + font-weight: 800; + padding: 4px 6px; + border-radius: 6px; +} + +.read-pay365-text { + flex: 1; + text-align: left; +} + +.read-pay365-chev { + opacity: 0.6; +} + +.read-chapter-nav { + margin-top: 48px; + padding-bottom: 16px; +} + +.read-chapter-nav--compact { + margin-top: 32px; +} + +.read-nav-buttons { + display: flex; + align-items: stretch; + gap: 12px; +} + +.read-nav-btn { + flex: 1; + background: rgba(28, 28, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 12px; + text-align: left; + cursor: pointer; +} + +.read-nav-next { + border-color: rgba(0, 206, 209, 0.3); +} + +.read-nav-label { + display: block; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; +} + +.read-nav-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.read-nav-sub { + flex: 1; + font-size: 14px; + color: rgba(255, 255, 255, 0.88); +} + +.read-nav-arr { + color: #00ced1; +} + +.read-nav-end { + flex: 1; + align-self: center; + font-size: 14px; + color: rgba(255, 255, 255, 0.4); +} + +.read-action-section { + margin-top: 20px; +} + +.read-action-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.read-action-tile { + flex: 1; + min-width: 104px; + background: rgba(28, 28, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 12px 10px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.read-action-icon { + font-size: 22px; + color: #00ced1; +} + +.read-action-text { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); +} + +.read-fab-moments { + position: fixed; + bottom: calc(26px + env(safe-area-inset-bottom)); + right: 18px; + width: 52px; + height: 52px; + border-radius: 50%; + border: none; + background: rgba(8, 145, 178, 0.85); + color: #fff; + font-size: 24px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + z-index: 150; + cursor: pointer; +} + +.read-pay365-float { + position: fixed; + left: 12px; + right: auto; + bottom: calc(24px + env(safe-area-inset-bottom)); + max-width: 78%; + padding: 10px 14px; + border-radius: 999px; + border: none; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.9), rgba(234, 88, 12, 0.88)); + color: #0d0d0f; + display: flex; + align-items: center; + gap: 8px; + z-index: 140; + cursor: pointer; + font-weight: 600; +} + +.read-pay365-float-chev { + margin-left: 4px; +} + +.read-modal-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.read-loading-mask { + align-items: center; +} + +.read-modal-sheet { + background: #1c1c1e; + border-radius: 16px; + padding: 20px; + width: min(360px, 100%); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.read-modal-title { + margin: 0 0 14px; + font-size: 18px; + color: #fff; +} + +.read-modal-body { + margin: 0 0 16px; + color: rgba(255, 255, 255, 0.75); + font-size: 14px; + line-height: 1.62; +} + +.read-modal-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.read-modal-btn-primary { + padding: 12px; + border-radius: 12px; + border: none; + background: #00ced1; + color: #041e1f; + font-weight: 600; + cursor: pointer; +} + +.read-modal-btn-ghost { + padding: 10px; + border-radius: 12px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; +} + +.read-modal-btn-outline { + padding: 12px; + border-radius: 12px; + border: 1px solid rgba(0, 206, 209, 0.45); + background: transparent; + color: #00ced1; + font-weight: 600; + cursor: pointer; +} + +.read-h5share-actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.read-h5share-actions .read-moments-copy { + margin-top: 0; +} + +.read-modal-close { + float: right; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + cursor: pointer; + margin-bottom: 8px; +} + +.read-muted { + color: rgba(255, 255, 255, 0.62); +} + +.read-gift-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin: 10px 0; +} + +.read-gift-slot { + padding: 10px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: #fff; +} + +.read-gift-slot.active { + border-color: #00ced1; + background: rgba(0, 206, 209, 0.12); +} + +.read-gift-label { + margin: 0 0 4px; +} + +.read-audit-tip { + text-align: center; + color: rgba(255, 255, 255, 0.52); +} + +.read-moments-banner { + text-align: center; + padding: 12px; + margin-bottom: 12px; + border-radius: 12px; + background: rgba(0, 206, 209, 0.1); + color: rgba(255, 255, 255, 0.88); +} + +.read-moments-pct { + color: #fbbf24; + font-weight: 800; +} + +.read-moments-copy { + width: 100%; + margin-top: 16px; + padding: 12px; + border-radius: 12px; + border: none; + background: #00ced1; + font-weight: 600; +} + +.read-moments-copy.done { + background: #22c55e; +} + +.read-loading-box { + background: rgba(28, 28, 30, 0.95); + padding: 24px; + border-radius: 14px; + text-align: center; +} + +.read-loading-spin { + width: 28px; + height: 28px; + border: 3px solid rgba(0, 206, 209, 0.3); + border-top-color: #00ced1; + border-radius: 50%; + animation: read-spin 0.72s linear infinite; + margin: 0 auto 12px; +} + +.read-loading-text { + color: rgba(255, 255, 255, 0.75); +} + +@keyframes read-spin { + to { + transform: rotate(360deg); + } +} + +.read-skel { + margin-bottom: 24px; +} + +.read-skel-meta { + height: 32px; + width: 100px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.06); + margin-bottom: 16px; + animation: read-pulse 1.4s infinite; +} + +.read-skel-title { + height: 28px; + width: 80%; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + margin-bottom: 24px; + animation: read-pulse 1.4s infinite; +} + +.read-skel-line { + height: 18px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + margin-bottom: 12px; + animation: read-pulse 1.4s infinite; +} +.read-skel-line--0 { width: 96%; } +.read-skel-line--1 { width: 88%; } +.read-skel-line--2 { width: 100%; } +.read-skel-line--3 { width: 72%; } +.read-skel-line--4 { width: 90%; } + +@keyframes read-pulse { + 50% { + opacity: 0.45; + } +} + +.read-gift-price { + margin: 10px 0; + font-size: 14px; + color: #fdba74; +} diff --git a/reactH5/src/pages/Read/ReadPage.tsx b/reactH5/src/pages/Read/ReadPage.tsx new file mode 100644 index 00000000..013a6136 --- /dev/null +++ b/reactH5/src/pages/Read/ReadPage.tsx @@ -0,0 +1,1552 @@ +import './ReadPage.css' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { request } from '@/api/request' +import { fetchReadExtras, getContentParseConfig } from '@/api/readExtras' +import { storage } from '@/api/storage' +import { getApiBaseUrl } from '@/api/types' +import { useApp, type AppGlobal } from '@/context/AppContext' +import { useToast } from '@/context/ToastContext' +import { fetchMiniprogramConfig, getMemoryConfig } from '@/api/getConfig' +import { parseContent, type ContentSegment } from '@/utils/contentParser' +import { + ACCESS_STATES, + canAccessFullContent, + determineAccessState, + type AccessState, +} from '@/utils/chapterAccessH5' +import { readingTrackerH5 } from '@/utils/readingTrackerH5' +import { parseScene } from '@/utils/sceneCompat' +import { getReferralCodeForPay, submitCkbLeadH5 } from '@/utils/soulBridge' +import { + getChapterReadMarketingRule, + markRuleCompletedOnServer, + type Pay365RuleUi, +} from '@/utils/readMarketingRuleH5' +import { + buildReadPromoClipboardBody, + buildReadShareUrl, + copyTextWithToast, + shareOrCopyPageUrl, +} from '@/utils/h5Share' + +/** + * H5 阅读页展示用默认文案(可走后台 read_preview_ui 覆盖)。 + * 与小程序 read 页的「朋友圈 / 右上角菜单」话术刻意区分,详见 docs/H5_SHARE.md。 + */ +const READ_UI_DEFAULTS: Record = { + singlePageUnlockTitle: '解锁全文', + singlePagePayButtonText: '支付 ¥{price} 解锁全文', + singlePageExpandedHint: + '预览页不能直接付款,务必先点底栏「前往小程序」。', + payTapModalTitle: '解锁说明', + payTapModalContent: + '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', + fullUnlockTitle: '', + fullUnlockDesc: '', + fullLockedProgressText: '', + fullPaywallTip: '复制链接分享给需要的人,一起学习还能赚佣金', + notLoginUnlockDesc: '', + notLoginPaywallTip: '复制链接分享给需要的人,一起学习还能赚佣金', + shareTipLine: '复制链接分享给需要的人,一起学习还能赚佣金', + momentsModalTitle: '分享本篇(网页版)', + momentsModalContent: + '在浏览器中可使用「系统分享」将页面发出;也可复制下方推广链接或带摘要的推广文案,粘贴到微信、社群或其它应用。', + momentsClipboardFooter: + '\n\n—— 以上为正文预览约 {percent}% ,阅读全文见:{url} ——', + timelineImageUrl: '', +} + +function buildReadUiFilled(pct: number, priceTok: string): Record { + const base = { ...READ_UI_DEFAULTS } + const filled: Record = {} + Object.keys(base).forEach((k) => { + filled[k] = String(base[k] || '') + .replace(/\{percent\}/g, String(pct)) + .replace(/\{price\}/g, String(priceTok)) + }) + return filled +} + +function normalizePreviewPercent(res: Record): number { + const tryNum = (v: unknown) => { + const n = typeof v === 'number' ? Math.round(v) : parseInt(String(v), 10) + if (!isNaN(n) && n >= 1 && n <= 100) return n + return undefined + } + const data = res.data as Record | undefined + const inner = data && data.previewPercent + const outer = res.previewPercent + return tryNum(inner) ?? tryNum(outer) ?? 20 +} + +function normalizeMentionSegments(segments: ContentSegment[][]): ContentSegment[][] { + if (!Array.isArray(segments)) return [] + return segments.map((row) => { + if (!Array.isArray(row)) return row + return row.map((seg) => { + if (!seg || seg.type !== 'mention') return seg + const nick = String((seg as { nickname?: string }).nickname || '') + .replace(/^[\s\u00a0\u200b\u3000]+/g, '') + .replace(/[\s\u00a0\u200b\u3000]+$/g, '') + return { ...seg, nickname: nick, mentionDisplay: '@' + nick } + }) + }) +} + +function formatPriceToken(n: number): string { + if (!isFinite(n)) return '1' + if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + return String(Number(n.toFixed(2))) +} + +function applyChapterMeta( + res: Record, + sectionPriceFallback: number +): { pct: number; readUi: Record } { + const rawPct = + typeof res.unpaidPreviewPercent === 'number' + ? res.unpaidPreviewPercent + : typeof res.unpaid_preview_percent === 'number' + ? res.unpaid_preview_percent + : null + const pct = + rawPct != null && !isNaN(Number(rawPct)) + ? Math.min(100, Math.max(1, Math.round(Number(rawPct)))) + : 20 + const priceNum = + res.price != null + ? Number(res.price) + : Number(sectionPriceFallback || 1) + const priceTok = formatPriceToken(priceNum) + const base = { ...READ_UI_DEFAULTS } + const rpu = res.readPreviewUi as Record | undefined + if (rpu && typeof rpu === 'object') { + Object.keys(rpu).forEach((k) => { + const v = rpu[k] + if (typeof v === 'string' && v.trim()) base[k] = v.trim() + }) + } + const filled: Record = {} + Object.keys(base).forEach((k) => { + filled[k] = String(base[k] || '') + .replace(/\{percent\}/g, String(pct)) + .replace(/\{price\}/g, priceTok) + }) + return { pct, readUi: filled } +} + +function getSectionTitle(id: string): string { + const titles: Record = { + '1.1': '荷包:电动车出租的被动收入模式', + '1.2': '老墨:资源整合高手的社交方法', + '1.3': '笑声背后的MBTI', + '1.4': '人性的三角结构:利益、情感、价值观', + '1.5': '沟通差的问题:为什么你说的别人听不懂', + '2.1': '相亲故事:你以为找的是人,实际是在找模式', + '2.2': '找工作迷茫者:为什么简历解决不了人生', + '2.3': '撸运费险:小钱困住大脑的真实心理', + '2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力', + '2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒', + '3.1': '3000万流水如何跑出来(退税模式解析)', + '8.1': '流量杠杆:抖音、Soul、飞书', + '9.14': '大健康私域:一个月150万的70后', + } + return titles[id] || `章节 ${id}` +} + +function parseBool(v: unknown): boolean { + if (typeof v === 'boolean') return v + const s = String(v || '') + .trim() + .toLowerCase() + return s === '1' || s === 'true' || s === 'yes' || s === 'on' +} + +function normalizeLinkTagLabel(raw: string): string { + return String(raw || '') + .replace(/^[##\s\u00a0\u200b\u3000]+/u, '') + .replace(/[\s\u00a0\u200b\u3000]+$/u, '') + .trim() + .toLowerCase() +} + +type ChapterJson = Record & { + id?: string + mid?: number + content?: string + data?: { content?: string; previewPercent?: number } + prev?: { id?: string; mid?: number; title?: string } | null + next?: { id?: string; mid?: number; title?: string } | null +} + +function getDisplayContent(res: ChapterJson): string { + const d = res.data + const c = d && typeof d.content === 'string' ? d.content : '' + const top = typeof res.content === 'string' ? res.content : '' + return c || top || '' +} + +type SegRowProps = { + row: ContentSegment[] + auditMode: boolean + onMention: (userId: string, nickname: string) => void + onLinkTag: (seg: ContentSegment & { type: 'linkTag' }) => void + onArticleLink: (url: string, text: string) => void + onImageTap: (src: string) => void +} + +function SegRow({ row, auditMode, onMention, onLinkTag, onArticleLink, onImageTap }: SegRowProps) { + if (row.length === 1 && row[0].type === 'heading') { + const h = row[0] as { level: number; text: string } + return ( +
+ {h.text} +
+ ) + } + if (row.length === 1 && row[0].type === 'quote') { + const q = row[0] as { text: string } + return ( +
+ {q.text} +
+ ) + } + if (row.length === 1 && row[0].type === 'table') { + const t = row[0] as { headers: string[]; rows: string[][] } + return ( +
+
+ {!!t.headers?.length && ( +
+ {t.headers.map((h, i) => ( + + {h} + + ))} +
+ )} + {t.rows.map((rrow, ri) => ( +
+ {rrow.map((cell, ci) => ( + + {cell} + + ))} +
+ ))} +
+
+ ) + } + if (row.length === 1 && row[0].type === 'listItem') { + const li = row[0] as { ordered?: boolean; number?: number; text: string } + return ( +
+ {li.ordered ? `${li.number ?? ''}.` : '•'} + {li.text} +
+ ) + } + if (row.length === 1 && row[0].type === 'image') { + const im = row[0] as { src: string } + return ( +
+ {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + onImageTap(im.src)} /> +
+ ) + } + if (row.length === 1 && row[0].type === 'video') { + if (auditMode) return null + const v = row[0] as { src: string } + return ( +
+
+ ) + } + + return ( +
+ + {row.map((seg, si) => { + if (!seg) return null + if (seg.type === 'text') + return {(seg as { text: string }).text} + if (seg.type === 'mention') { + const m = seg as { userId: string; nickname: string; mentionDisplay: string } + return ( + onMention(m.userId, m.nickname)} + onKeyDown={(e) => e.key === 'Enter' && onMention(m.userId, m.nickname)} + > + {m.mentionDisplay} + + ) + } + if (seg.type === 'linkTag') { + const t = seg as ContentSegment & { type: 'linkTag'; label: string } + return ( + onLinkTag(t)} + onKeyDown={(e) => e.key === 'Enter' && onLinkTag(t)} + > + #{t.label} + + ) + } + if (seg.type === 'link') { + const l = seg as { text: string; url: string } + return ( + onArticleLink(l.url, l.text)} + onKeyDown={(e) => e.key === 'Enter' && onArticleLink(l.url, l.text)} + > + {l.text} + + ) + } + return null + })} + + {row.map((seg, sj) => { + if (!seg) return null + if (seg.type === 'image') { + const im = seg as { src: string } + return ( + onImageTap(im.src)} + /> + ) + } + if (seg.type === 'video') { + if (auditMode) return null + const v = seg as { src: string } + return ( +
+ ) +} + +export function ReadPage() { + const [sp] = useSearchParams() + const navigate = useNavigate() + const location = useLocation() + + const openLoginPage = useCallback(() => { + navigate('/login', { + state: { + returnTo: `${location.pathname}${location.search || ''}`, + desc: '登录后可购买章节、解锁更多内容', + }, + }) + }, [navigate, location.pathname, location.search]) + const { showToast } = useToast() + const { + global, + setGlobal, + markSectionAsRead, + touchRecentSection, + getReadCount, + getBrowseDistinctChapterCount, + refreshConfig, + } = useApp() + + const auditMode = global.auditMode + + const [loading, setLoading] = useState(true) + const [accessState, setAccessState] = useState(ACCESS_STATES.UNKNOWN) + const [sectionId, setSectionId] = useState('') + const [sectionMid, setSectionMid] = useState(null) + const [section, setSection] = useState<{ id: string; title: string; isFree: boolean; price: number } | null>( + null + ) + const [chapterTitle, setChapterTitle] = useState('') + const [contentSegments, setContentSegments] = useState([]) + const [previewPercent, setPreviewPercent] = useState(20) + const [previewMaxHeightPx, setPreviewMaxHeightPx] = useState(0) + const [readingProgress, setReadingProgress] = useState(0) + const [sectionPrice, setSectionPrice] = useState(1) + const [fullBookPrice, setFullBookPrice] = useState(365) + const [, setPurchasedCount] = useState(0) + const [fullbookShowThreshold, setFullbookShowThreshold] = useState(3) + const [shareRate, setShareRate] = useState(90) + const [readUi, setReadUi] = useState>(() => buildReadUiFilled(20, '1')) + const [prevSection, setPrevSection] = useState<{ + id: string + mid: number | null + title: string + } | null>(null) + const [nextSection, setNextSection] = useState<{ + id: string + mid: number | null + title: string + } | null>(null) + const [showPosterModal, setShowPosterModal] = useState(false) + const [showH5ShareSheet, setShowH5ShareSheet] = useState(false) + const [h5PromoCopied, setH5PromoCopied] = useState(false) + const [showGiftModal, setShowGiftModal] = useState(false) + const [giftQuantity, setGiftQuantity] = useState(6) + const [giftPaid, setGiftPaid] = useState(false) + const [giftPaying, setGiftPaying] = useState(false) + const [giftUnitPrice, setGiftUnitPrice] = useState(0) + const [isPaying, setIsPaying] = useState(false) + const [, setPendingGiftRequestSn] = useState('') + const [pay365AnchorVisible, setPay365AnchorVisible] = useState(false) + const [pay365AnchorLabel, setPay365AnchorLabel] = useState('') + const [pay365RuleSnapshot, setPay365RuleSnapshot] = useState(null) + const [pay365ConfirmOpen, setPay365ConfirmOpen] = useState(false) + const [showFullBookCta, setShowFullBookCta] = useState(false) + const [paywall365Primary, setPaywall365Primary] = useState(false) + + const previewMeasureRef = useRef(null) + const scrollWrapRef = useRef(null) + const pendingAutoPayRef = useRef(false) + + const navBarPadTop = typeof window !== 'undefined' ? 'env(safe-area-inset-top, 44px)' : '44px' + const scrollTopInset = typeof window !== 'undefined' ? 56 : 56 + + const userId = global.userInfo?.id || '' + + const getShareRefCode = useCallback( + () => (storage.getReferralCode() || global.userInfo?.id || '').trim(), + [global.userInfo?.id] + ) + + const buildCurrentReadShareUrl = useCallback(() => { + if (!sectionId) return typeof window !== 'undefined' ? `${window.location.origin}/read` : '/read' + return buildReadShareUrl({ + sectionId, + sectionMid, + referralRef: getShareRefCode() || undefined, + }) + }, [sectionId, sectionMid, getShareRefCode]) + + useEffect(() => { + const base = '卡若创业派对' + const t = (section?.title || '').trim() + document.title = t ? `${t} · ${base}` : base + return () => { + document.title = base + } + }, [section?.title]) + + const buildChapterUrl = useCallback( + (id: string, mid: number | null | undefined, uid: string | null) => { + let url = + mid != null && mid > 0 + ? `/api/miniprogram/book/chapter/by-mid/${mid}` + : `/api/miniprogram/book/chapter/by-id/${encodeURIComponent(id)}` + if (uid) url += `${url.includes('?') ? '&' : '?'}userId=${encodeURIComponent(uid)}` + return url + }, + [] + ) + + const syncPurchaseFromServer = useCallback(async () => { + const uid = global.userInfo?.id + if (!uid) return + try { + const res = await request<{ + success?: boolean + data?: { hasFullBook?: boolean; purchasedSections?: string[] } + }>({ url: `/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(uid)}`, silent: true }) + if (!res?.success || !res.data) return + const purchasedSections = res.data.purchasedSections || [] + const hasFullBook = !!res.data.hasFullBook + setGlobal((g: AppGlobal) => ({ + ...g, + hasFullBook, + purchasedSections, + userInfo: g.userInfo ? { ...g.userInfo, hasFullBook, purchasedSections } : null, + })) + const prevStored = storage.getUserInfo>() + if (prevStored && typeof prevStored === 'object') { + storage.setUserInfo({ ...prevStored, hasFullBook, purchasedSections }) + } + setPurchasedCount(purchasedSections.length) + } catch { + /* */ + } + }, [global.userInfo?.id, setGlobal]) + + /** 读书会双按钮样式(与小程序 computeShowFullBookCta 对齐) */ + const computePurchaseBtnClasses = useCallback( + (showFullBook: boolean, paywall365Pri: boolean) => { + let sectionCls = 'read-purchase-btn read-purchase-section' + let fullbookCls = 'read-purchase-btn read-purchase-fullbook' + if (!showFullBook) { + sectionCls += ' read-purchase-section--primary' + } else if (paywall365Pri) { + sectionCls += ' read-purchase-section--secondary' + } else { + sectionCls += ' read-purchase-section--primary' + fullbookCls += ' read-purchase-fullbook--secondary' + } + return { sectionCls, fullbookCls } + }, + [] + ) + + const [sectionPurchaseBtnClass, setSectionPurchaseBtnClass] = useState( + 'read-purchase-btn read-purchase-section read-purchase-section--primary' + ) + const [fullbookPurchaseBtnClass, setFullbookPurchaseBtnClass] = useState('read-purchase-btn read-purchase-fullbook') + + const refreshPay365 = useCallback(async () => { + const uid = global.userInfo?.id + if (!uid || global.hasFullBook || global.isVip) { + setPay365AnchorVisible(false) + setPay365RuleSnapshot(null) + return + } + const rule = await getChapterReadMarketingRule(uid, { + hasFullBook: global.hasFullBook, + isVip: global.isVip, + getReadCount, + getBrowseDistinctChapterCount, + }) + if (rule?.revealMode === 'anchor' && rule.action === 'pay365') { + setPay365AnchorVisible(true) + setPay365RuleSnapshot(rule) + setPay365AnchorLabel(rule.anchorLabel || '加入 365 读书会,解锁全部内容') + } else { + setPay365AnchorVisible(false) + setPay365RuleSnapshot(null) + } + }, [ + global.userInfo?.id, + global.hasFullBook, + global.isVip, + getReadCount, + getBrowseDistinctChapterCount, + ]) + + const applyPrevNext = useCallback((res: ChapterJson) => { + const p = res.prev + const n = res.next + setPrevSection( + p + ? { id: String(p.id || ''), mid: p.mid ?? null, title: p.title || getSectionTitle(String(p.id)) } + : null + ) + setNextSection( + n + ? { id: String(n.id || ''), mid: n.mid ?? null, title: n.title || getSectionTitle(String(n.id)) } + : null + ) + }, []) + + const locked = accessState === ACCESS_STATES.LOCKED_NOT_LOGIN || accessState === ACCESS_STATES.LOCKED_NOT_PURCHASED + + useEffect(() => { + const th = fullbookShowThreshold + const pc = global.purchasedSections.length + const hasPrev = !!prevSection + let candidate = pc >= th || (pc >= 2 && hasPrev) + if (pay365AnchorVisible && pay365RuleSnapshot) candidate = false + setShowFullBookCta(candidate) + const browseN = getBrowseDistinctChapterCount() + const pwPrimary = !!(candidate && browseN >= th) + setPaywall365Primary(pwPrimary) + const { sectionCls, fullbookCls } = computePurchaseBtnClasses(candidate, pwPrimary) + setSectionPurchaseBtnClass(sectionCls) + setFullbookPurchaseBtnClass(fullbookCls) + }, [ + global.purchasedSections, + prevSection, + pay365AnchorVisible, + pay365RuleSnapshot, + fullbookShowThreshold, + getBrowseDistinctChapterCount, + computePurchaseBtnClasses, + ]) + + useLayoutEffect(() => { + if (!locked || !previewMeasureRef.current || !contentSegments.length) { + if (!locked && previewMaxHeightPx !== 0) setPreviewMaxHeightPx(0) + return + } + setPreviewMaxHeightPx(0) + const id = requestAnimationFrame(() => { + const el = previewMeasureRef.current + if (!el) return + const fullH = el.scrollHeight + if (!fullH || fullH < 20) return + const pct = Math.min(Math.max(previewPercent || 20, 1), 100) + const clipped = Math.max(120, Math.floor((fullH * pct) / 100)) + setPreviewMaxHeightPx(clipped) + }) + return () => cancelAnimationFrame(id) + }, [locked, contentSegments, previewPercent, accessState]) + + const loadContentSegments = useCallback( + async (id: string, state: AccessState, chapterRes: ChapterJson) => { + const extras = await fetchReadExtras(false) + const assetBase = getApiBaseUrl().replace(/\/$/, '') + const parseCfg = getContentParseConfig(assetBase, extras) + const displayContent = getDisplayContent(chapterRes) + const meta = applyChapterMeta(chapterRes as Record, sectionPrice) + setReadUi(meta.readUi) + setPreviewPercent(normalizePreviewPercent(chapterRes as Record)) + + const spFallback = sectionPrice + const stitle = + chapterRes.sectionTitle || (chapterRes.data as { sectionTitle?: string } | undefined)?.sectionTitle || '' + const title = + typeof stitle === 'string' && stitle.trim() + ? stitle.trim() + : getSectionTitle(chapterRes.id || id) + + const isFree = + chapterRes.isFree === true || (chapterRes.price !== undefined && Number(chapterRes.price) === 0) + const priceNum = chapterRes.price != null ? Number(chapterRes.price) : spFallback + + setSection({ + id: chapterRes.id || id, + title, + isFree: !!isFree, + price: isNaN(priceNum) ? spFallback : priceNum, + }) + setChapterTitle(title) + + if (chapterRes.mid != null) setSectionMid(Number(chapterRes.mid)) + + if (displayContent) { + const { segments } = parseContent(displayContent, parseCfg as Parameters[1]) + setContentSegments(normalizeMentionSegments(segments as ContentSegment[][])) + + touchRecentSection(id) + if (canAccessFullContent(state)) { + markSectionAsRead(id) + readingTrackerH5.init(id, userId || null, (top) => { + scrollWrapRef.current?.scrollTo({ top, behavior: 'smooth' }) + }) + } else { + readingTrackerH5.cleanup(userId || null) + } + } + + applyPrevNext(chapterRes) + void refreshPay365() + }, + [ + sectionPrice, + touchRecentSection, + markSectionAsRead, + userId, + applyPrevNext, + refreshPay365, + ] + ) + + const fetchChapterAndApply = useCallback( + async (id: string, mid: number | null, uid: string | null): Promise => { + try { + const raw = await request({ + url: buildChapterUrl(id, mid, uid), + silent: true, + timeout: 15000, + }) + return raw && typeof raw === 'object' ? raw : null + } catch { + return null + } + }, + [buildChapterUrl] + ) + + /** 代付领取 */ + const tryRedeemGift = useCallback( + async (sn: string): Promise => { + const uid = (storage.getUserInfo<{ id?: string }>()?.id || global.userInfo?.id || '').trim() + if (!uid || !storage.getToken()) { + showToast('登录后将自动领取并解锁') + openLoginPage() + return false + } + try { + const res = await request<{ success?: boolean }>({ + url: '/api/miniprogram/gift-pay/redeem', + method: 'POST', + data: { requestSn: sn, userId: uid }, + silent: true, + }) + if (res?.success) return true + showToast('领取未完成或名额已满') + } catch { + showToast('领取失败') + } + setPendingGiftRequestSn('') + return false + }, + [global.userInfo?.id, showToast, openLoginPage] + ) + + /** 首轮加载 */ + useEffect(() => { + let cancelled = false + + async function boot() { + setLoading(true) + setAccessState(ACCESS_STATES.UNKNOWN) + pendingAutoPayRef.current = parseBool(sp.get('openPay')) + + void refreshConfig(false) + void fetchReadExtras(false) + + const sceneStr = sp.get('scene') || '' + const parsedScene = parseScene(sceneStr) + + const isGift = sp.get('gift') === '1' || sp.get('gift') === 'true' + const giftSn = ( + sp.get('requestSn') || + (isGift ? sp.get('ref') || parsedScene.ref : '') || + '' + ).trim() + + let ref = (!isGift ? sp.get('ref') || parsedScene.ref : '') || '' + if (ref) { + localStorage.setItem('referral_code', ref) + localStorage.setItem('pendingReferralCode', ref) + void request({ + url: '/api/miniprogram/referral/visit', + method: 'POST', + silent: true, + data: { + referralCode: ref, + visitorOpenId: storage.getOpenId() || '', + visitorId: global.userInfo?.id || '', + source: 'h5', + page: '/read', + }, + }).catch(() => {}) + if (global.isLoggedIn && global.userInfo?.id) { + void request({ + url: '/api/miniprogram/referral/bind', + method: 'POST', + silent: true, + data: { userId: global.userInfo.id, referralCode: ref }, + }).catch(() => {}) + } + } + + let mid = parseInt(sp.get('mid') || '', 10) || parsedScene.mid || 0 + let id = + sp.get('id') || + parsedScene.id || + '' + + if (mid && !id) { + const ch = await fetchChapterAndApply('', mid, global.userInfo?.id || null) + if (ch?.id) id = String(ch.id) + } + + if (!id) { + if (!cancelled) { + showToast('章节参数缺失') + setAccessState(ACCESS_STATES.ERROR) + setLoading(false) + } + return + } + + if (!cancelled) { + setSectionId(id) + setSectionMid(mid || null) + setPendingGiftRequestSn(giftSn) + } + + const cfg = getMemoryConfig() || (await fetchMiniprogramConfig(false)) + const prices = cfg?.prices as { section?: number; fullbook?: number; fullbookShowThreshold?: number } | undefined + + await request>({ + url: '/api/miniprogram/config', + silent: true, + }) + .then((legacy) => { + const fullTh = + (legacy?.fullbookShowThreshold as number) ?? + (legacy?.configs as { chapter_config?: { fullbook_show_threshold?: number } })?.chapter_config + ?.fullbook_show_threshold + if (typeof fullTh === 'number' && fullTh > 0) setFullbookShowThreshold(fullTh) + }) + .catch(() => {}) + + const sectionP = Number(prices?.section) > 0 ? Number(prices?.section) : 1 + const fullP = Number(prices?.fullbook) > 0 ? Number(prices?.fullbook) : 365 + const shr = + cfg && typeof (cfg as unknown as { shareRate?: number }).shareRate === 'number' + ? (cfg as unknown as { shareRate: number }).shareRate + : 90 + if (!cancelled) { + setSectionPrice(sectionP) + setFullBookPrice(fullP) + setShareRate(shr) + setPurchasedCount(global.purchasedSections.length) + } + + const chapterRes = await fetchChapterAndApply(id, mid || null, global.userInfo?.id || null) + if (!chapterRes || cancelled) { + setAccessState(ACCESS_STATES.ERROR) + setLoading(false) + return + } + + const st = await determineAccessState(id, chapterRes as Record, { + userId: global.userInfo?.id, + }) + + setAccessState(st) + await loadContentSegments(id, st, chapterRes) + + if (giftSn && !cancelled) { + const ok = await tryRedeemGift(giftSn) + if (ok) { + await syncPurchaseFromServer() + const fresh = await fetchChapterAndApply(id, mid || null, global.userInfo?.id || null) + if (fresh) { + const st2 = await determineAccessState(id, fresh as Record, { + userId: global.userInfo?.id, + }) + setAccessState(st2) + await loadContentSegments(id, st2, fresh) + } + setPendingGiftRequestSn('') + } + } + + setLoading(false) + } + + void boot() + return () => { + cancelled = true + readingTrackerH5.cleanup(global.userInfo?.id || null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅以 query id/mid/ref 等与阅读页挂载为首轮依赖 + }, []) + + const onScrollMain = () => { + const el = scrollWrapRef.current + if (!el) return + const scrollTop = el.scrollTop + const scrollHeight = el.scrollHeight + const clientHeight = el.clientHeight + const total = scrollHeight - clientHeight + const progress = total > 0 ? Math.min((scrollTop / total) * 100, 100) : 0 + setReadingProgress(progress) + if (canAccessFullContent(accessState)) { + readingTrackerH5.updateProgress(scrollTop, scrollHeight, clientHeight, userId || null, () => {}) + } + } + + const handleMentionTap = async (mentionUserId: string, nickname: string) => { + await submitCkbLeadH5( + { global }, + showToast, + (p) => navigate(p), + { targetUserId: mentionUserId, targetNickname: nickname, source: 'article_mention' } + ) + } + + const resolveExtrasLinkTag = useCallback( + async (label: string) => { + const extras = await fetchReadExtras(false) + const normalized = normalizeLinkTagLabel(label) + for (const t of extras.linkTags) { + const lab = normalizeLinkTagLabel(String(t.label || '')) + const aliases = String(t.aliases || '') + .split(',') + .map((a) => normalizeLinkTagLabel(a)) + if (lab === normalized || aliases.includes(normalized)) return t as Record + } + return null + }, + [] + ) + + const handleLinkTagTap = async (seg: ContentSegment & { type: 'linkTag' }) => { + const merged = + ((await resolveExtrasLinkTag(seg.label)) as Record | null) || (seg as unknown as Record) + + let url = String((merged.url as string) || seg.url || '').trim() + const tagType = String((merged.type as string) || seg.tagType || 'url') + .trim() + .toLowerCase() + const pagePath = String((merged.pagePath as string) || seg.pagePath || '').trim() + const mpKey = String((merged.mpKey as string) || seg.mpKey || '').trim() + + if (tagType === 'miniprogram' || tagType === 'mp' || mpKey) { + showToast(`「${seg.label}」请在微信小程序「卡若创业派对」内打开`) + return + } + if (/^https?:\/\//i.test(url)) { + window.open(url, '_blank', 'noopener,noreferrer') + return + } + if (pagePath) { + const p = pagePath.startsWith('/') ? pagePath : `/${pagePath}` + navigate(p) + return + } + showToast(url ? '请在小程序内打开链接' : '链接未配置') + } + + const handleArticleLink = (url: string, _text: string) => { + const u = (url || '').trim() + if (/^https?:\/\//i.test(u)) { + window.open(u, '_blank', 'noopener,noreferrer') + return + } + showToast('请在小程序内打开该链接') + } + + const goRead = (id: string, mid: number | null) => { + const q = mid && mid > 0 ? `?id=${encodeURIComponent(id)}&mid=${mid}` : `?id=${encodeURIComponent(id)}` + navigate(`/read${q}`) + window.location.reload() + } + + const goBack = () => { + navigate(-1) + } + + function consumeResponseOk(res: { success?: boolean; data?: { ok?: boolean } } | undefined) { + if (!res) return false + if (res.success === true) return true + const d = res.data + return !!(d && (d as { ok?: boolean }).ok === true) + } + + const handlePurchaseSection = async () => { + if (!global.isLoggedIn) { + openLoginPage() + return + } + const price = section?.price ?? sectionPrice + setIsPaying(true) + try { + await syncPurchaseFromServer() + if (global.purchasedSections.includes(sectionId)) { + showToast('已购买过此章节') + return + } + const referralCode = getReferralCodeForPay() + const bal = await request<{ data?: { balance?: number } }>({ + url: `/api/miniprogram/balance?userId=${encodeURIComponent(userId)}`, + silent: true, + }) + const balance = bal?.data?.balance || 0 + if (balance >= price && userId) { + try { + const consumeRes = await request<{ success?: boolean }>({ + url: '/api/miniprogram/balance/consume', + method: 'POST', + data: { + userId, + productType: 'section', + productId: sectionId, + amount: price, + referralCode: referralCode || undefined, + }, + }) + if (consumeResponseOk(consumeRes)) { + showToast('购买成功') + await onPaymentDone() + return + } + } catch { + /* */ + } + } + showToast('微信支付请使用微信小程序「卡若创业派对」完成;余额充足时已自动抵扣') + } finally { + setIsPaying(false) + } + } + + const handlePurchaseSectionRef = useRef(handlePurchaseSection) + handlePurchaseSectionRef.current = handlePurchaseSection + + useEffect(() => { + if (loading) return + if (!parseBool(sp.get('openPay'))) return + if (!global.isLoggedIn || !userId) return + if (accessState !== ACCESS_STATES.LOCKED_NOT_PURCHASED) return + if (section?.isFree) return + if (!pendingAutoPayRef.current) return + pendingAutoPayRef.current = false + window.setTimeout(() => void handlePurchaseSectionRef.current(), 280) + }, [loading, accessState, global.isLoggedIn, userId, sp, section?.isFree]) + + const handlePurchaseFullBook = async () => { + if (!global.isLoggedIn) { + openLoginPage() + return + } + if (global.hasFullBook) { + showToast('已加入读书会') + return + } + setIsPaying(true) + try { + await syncPurchaseFromServer() + const referralCode = getReferralCodeForPay() + const bal = await request<{ data?: { balance?: number } }>({ + url: `/api/miniprogram/balance?userId=${encodeURIComponent(userId)}`, + silent: true, + }) + const balance = bal?.data?.balance || 0 + if (balance >= fullBookPrice && userId) { + try { + const consumeRes = await request<{ success?: boolean }>({ + url: '/api/miniprogram/balance/consume', + method: 'POST', + data: { + userId, + productType: 'fullbook', + productId: 'fullbook', + amount: fullBookPrice, + referralCode: referralCode || undefined, + }, + }) + if (consumeResponseOk(consumeRes)) { + showToast('加入成功') + await onPaymentDone() + return + } + } catch { + /* */ + } + } + showToast('微信支付请使用微信小程序完成;余额充足时已自动抵扣') + } finally { + setIsPaying(false) + } + } + + const onPaymentDone = async () => { + await syncPurchaseFromServer() + await new Promise((r) => setTimeout(r, 1800)) + const ch = await fetchChapterAndApply(sectionId, sectionMid, global.userInfo?.id || null) + if (!ch) { + showToast('请稍后刷新页面') + return + } + let st = await determineAccessState(sectionId, ch as Record, { userId }) + if (st !== ACCESS_STATES.UNLOCKED_PURCHASED) { + await new Promise((r) => setTimeout(r, 1000)) + st = await determineAccessState(sectionId, ch as Record, { userId }) + } + setAccessState(st) + await loadContentSegments(sectionId, st, ch) + await refreshPay365() + } + + const copyShareLink = () => { + void copyTextWithToast(buildCurrentReadShareUrl(), showToast, '推广链接已复制') + } + + /** 调用系统分享面板;不支持时复制链接(见 utils/h5Share) */ + const shareToFriend = async () => { + const url = buildCurrentReadShareUrl() + await shareOrCopyPageUrl({ + title: section?.title || '阅读', + text: `${section?.title || ''} · 卡若创业派对`, + url, + showToast, + sharedToast: '已打开系统分享', + copyToast: '链接已复制', + }) + } + + const openH5ShareSheet = () => { + setShowH5ShareSheet(true) + setH5PromoCopied(false) + } + + const onCopyH5PromoBody = async () => { + const pct = previewPercent || 20 + const url = buildCurrentReadShareUrl() + const excerpt = section?.title || '' + const body = buildReadPromoClipboardBody({ + sectionTitle: excerpt, + previewPercent: pct, + footerTemplate: readUi.momentsClipboardFooter, + pageUrl: url, + }) + await copyTextWithToast(body, showToast, '推广文案已复制') + setH5PromoCopied(true) + } + + const onPay365Tap = () => { + const r = pay365RuleSnapshot + if (!r || r.action !== 'pay365') return + setPay365ConfirmOpen(true) + } + + const confirmPay365Modal = async () => { + setPay365ConfirmOpen(false) + const r = pay365RuleSnapshot + if (!r?.serverRuleId) { + await handlePurchaseFullBook() + return + } + await markRuleCompletedOnServer(userId, r.serverRuleId) + await handlePurchaseFullBook() + } + + const giftTotal = ((giftUnitPrice || section?.price || sectionPrice) * giftQuantity).toFixed(2) + + useEffect(() => { + const u = section?.price ?? sectionPrice ?? 0 + setGiftUnitPrice(typeof u === 'number' && !isNaN(u) ? u : 0) + }, [section?.price, sectionPrice]) + + const openGiftModal = () => { + if (auditMode) { + showToast('审核中,暂不支持代付') + return + } + if (!global.isLoggedIn) { + showToast('请先登录') + openLoginPage() + return + } + setShowGiftModal(true) + setGiftPaid(false) + setGiftQuantity(6) + setGiftPaying(false) + } + + const confirmGiftPay = async () => { + if (giftPaying) return + if (!global.userInfo?.id) return + setGiftPaying(true) + try { + const createRes = await request<{ success?: boolean; requestSn?: string; error?: string }>({ + url: '/api/miniprogram/gift-pay/create', + method: 'POST', + data: { + userId, + productType: 'section', + productId: sectionId, + quantity: giftQuantity, + }, + }) + if (!createRes?.success || !createRes.requestSn) { + throw new Error(createRes?.error || '创建失败') + } + showToast( + `代付单已生成(${createRes.requestSn.slice(0, 8)}…)。请在微信小程序「卡若创业派对」阅读页完成代付支付与分享`, + ) + setGiftPaid(false) + } catch (e) { + showToast(e instanceof Error ? e.message : '创建失败') + } finally { + setGiftPaying(false) + setShowGiftModal(false) + } + } + + const navChapterLine = chapterTitle || section?.title || '' + + /** H5 pay365 成功后 consume 同上 */ + const handlePurchaseSectionMem = handlePurchaseSection + + const unlocked = accessState === ACCESS_STATES.FREE || accessState === ACCESS_STATES.UNLOCKED_PURCHASED + + const previewWrapStyle = + locked && previewMaxHeightPx > 0 + ? { maxHeight: `${previewMaxHeightPx}px`, overflow: 'hidden' as const } + : undefined + + return ( +
+
+
+
+ +
+
+ +
+ {navChapterLine} +
+ +
+
+ +
+ +
+
+ {accessState === ACCESS_STATES.UNKNOWN && loading ? ( +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : null} + + {section ? ( +
+
+ {section.id} + {section.isFree ? 免费 : null} +
+

{section.title}

+
+ ) : null} + + {accessState === ACCESS_STATES.ERROR ? ( +
+

无法加载章节,请检查网络后重试

+ +
+ ) : null} + + {(unlocked || locked) && !loading ? ( +
+
+
+ {contentSegments.map((row, i) => ( + window.open(src, '_blank')} + /> + ))} +
+ {locked ?
: null} +
+ + {locked ? ( +
+
+
+ 解锁完整内容,分享得到{' '} + {shareRate}%{' '} + 收益 +
+ + {!auditMode && accessState === ACCESS_STATES.LOCKED_NOT_LOGIN ? ( + + ) : null} + {!auditMode && accessState === ACCESS_STATES.LOCKED_NOT_LOGIN ? ( + + ) : null} + + {!auditMode && accessState === ACCESS_STATES.LOCKED_NOT_PURCHASED ? ( +
+ {pay365AnchorVisible ? ( + + ) : null} + +
+ + {showFullBookCta ? ( + + ) : null} +
+
+ ) : null} + {auditMode ?

审核中,暂不支持购买

: null} +

{readUi.shareTipLine}

+
+ ) : ( + <> +
+
+ {prevSection ? ( + + ) : ( + + )} + {nextSection ? ( + + ) : ( +
已是最后一篇
+ )} +
+ {!auditMode ? ( +
+
+ + + {global.isLoggedIn ? ( + + ) : null} +
+
+ ) : null} +
+ + )} + + {locked ? ( +
+
+ {prevSection ? ( + + ) : ( + + )} + {nextSection ? ( + + ) : ( +
已是最后一篇
+ )} +
+
+ ) : null} +
+ ) : null} +
+
+ + {unlocked && !auditMode ? ( + + ) : null} + + {pay365AnchorVisible && unlocked ? ( + + ) : null} + + {showPosterModal ? ( +
setShowPosterModal(false)}> +
e.stopPropagation()}> +

生成海报

+

+ 海报生成与_canvas 画布能力在微信小程序「卡若创业派对」中使用体验最佳。请将本页分享到微信内打开或直接搜索小程序同名阅读。 +

+
+ + +
+
+
+ ) : null} + + {showH5ShareSheet ? ( +
setShowH5ShareSheet(false)}> +
e.stopPropagation()}> + +

{readUi.momentsModalTitle}

+
+ 好友购买,你得 + {shareRate}% + 收益 +
+

{readUi.momentsModalContent}

+
+ + + +
+
+
+ ) : null} + + {showGiftModal ? ( +
setShowGiftModal(false)}> +
e.stopPropagation()}> +

生成代付链接

+

{section?.title}

+ {!giftPaid ? ( + <> +

选择名额

+
+ {[6, 30, 100, 1000].map((q) => ( + + ))} +
+

+ ¥{giftUnitPrice} × {giftQuantity} = ¥{giftTotal} +

+ + + + ) : ( +

在微信小程序中分享的代付卡片可让好友一键领取本章。

+ )} +
+
+ ) : null} + + {pay365ConfirmOpen && pay365RuleSnapshot ? ( +
setPay365ConfirmOpen(false)}> +
e.stopPropagation()}> +

{pay365RuleSnapshot.title}

+

{pay365RuleSnapshot.message}

+
+ + +
+
+
+ ) : null} + + {isPaying ? ( +
+
+
+ 处理中... +
+
+ ) : null} +
+ ) +} diff --git a/reactH5/src/router.tsx b/reactH5/src/router.tsx index f41d00c1..d7c50b04 100644 --- a/reactH5/src/router.tsx +++ b/reactH5/src/router.tsx @@ -5,6 +5,7 @@ import { ChaptersPage } from '@/pages/Chapters/ChaptersPage' import { MatchPage } from '@/pages/Match/MatchPage' import { MyPage } from '@/pages/My/MyPage' import { DevLoginPage } from '@/pages/DevLogin/DevLoginPage' +import { ForgotPasswordPage } from '@/pages/ForgotPassword/ForgotPasswordPage' import { AgreementPage } from '@/pages/Legal/AgreementPage' import { PrivacyPage } from '@/pages/Legal/PrivacyPage' import { @@ -14,6 +15,7 @@ import { ReadingRecordsP2, ReferralP2, } from '@/pages/Placeholder/P2SubPage' +import { ReadPage } from '@/pages/Read/ReadPage' export function AppRouter() { return ( @@ -25,6 +27,7 @@ export function AppRouter() { } /> } /> + } /> } /> } /> } /> @@ -44,7 +47,7 @@ export function AppRouter() { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/reactH5/src/utils/chapterAccessH5.ts b/reactH5/src/utils/chapterAccessH5.ts new file mode 100644 index 00000000..0c5d64da --- /dev/null +++ b/reactH5/src/utils/chapterAccessH5.ts @@ -0,0 +1,80 @@ +import { request } from '@/api/request' + +export const ACCESS_STATES = { + UNKNOWN: 'unknown', + FREE: 'free', + LOCKED_NOT_LOGIN: 'locked_not_login', + LOCKED_NOT_PURCHASED: 'locked_not_purchased', + UNLOCKED_PURCHASED: 'unlocked_purchased', + ERROR: 'error', +} as const + +export type AccessState = (typeof ACCESS_STATES)[keyof typeof ACCESS_STATES] + +export function isFreeFromChapterData(chapterData: Record | null | undefined): boolean { + if (!chapterData) return false + if (chapterData.isFree === true) return true + if (chapterData.price !== undefined && Number(chapterData.price) === 0) return true + return false +} + +async function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} + +async function requestWithRetry(url: string, maxRetries = 2): Promise<{ success?: boolean; data?: { isPurchased?: boolean; reason?: string } }> { + let last: Error | null = null + for (let i = 0; i < maxRetries; i++) { + try { + return await request({ url, silent: true, timeout: 8000 }) + } catch (e) { + last = e instanceof Error ? e : new Error('fail') + if (i < maxRetries - 1) await sleep(1000 * (i + 1)) + } + } + throw last || new Error('fail') +} + +export async function determineAccessState( + sectionId: string, + chapterData: Record | null | undefined, + opts: { userId?: string | null } +): Promise { + try { + if (isFreeFromChapterData(chapterData)) return ACCESS_STATES.FREE + + const userId = (opts.userId || '').trim() + if (!userId) return ACCESS_STATES.LOCKED_NOT_LOGIN + + const url = `/api/miniprogram/user/check-purchased?userId=${encodeURIComponent(userId)}&type=section&productId=${encodeURIComponent(sectionId)}` + const res = await requestWithRetry(url, 2) + if (res.success && res.data?.isPurchased) { + return ACCESS_STATES.UNLOCKED_PURCHASED + } + return ACCESS_STATES.LOCKED_NOT_PURCHASED + } catch { + return ACCESS_STATES.ERROR + } +} + +export function canAccessFullContent(accessState: AccessState): boolean { + return accessState === ACCESS_STATES.FREE || accessState === ACCESS_STATES.UNLOCKED_PURCHASED +} + +export async function refreshUserPurchaseStatus(userId: string, patchUser: (u: Record) => void) { + if (!userId) return + try { + const res = await request<{ + success?: boolean + data?: { hasFullBook?: boolean; purchasedSections?: string[] } + }>({ url: `/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(userId)}` }) + if (res.success && res.data) { + patchUser({ + hasFullBook: res.data.hasFullBook, + purchasedSections: res.data.purchasedSections || [], + }) + } + } catch { + /* */ + } +} diff --git a/reactH5/src/utils/contentParser.ts b/reactH5/src/utils/contentParser.ts index 0bf70ce8..bfe9ce5b 100644 --- a/reactH5/src/utils/contentParser.ts +++ b/reactH5/src/utils/contentParser.ts @@ -1,7 +1,27 @@ -/** 与 miniprogram contentParser 对齐 */ -export function cleanSingleLineField(s: string | null | undefined): string { - return String(s || '') - .replace(/\r?\n/g, ' ') - .replace(/\s+/g, ' ') - .trim() -} +/** 与 miniprogram utils/contentParser 同源,见 mpContentParser.js */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error -- 无单独 .d.ts;与小程序同源 JS +export { parseContent, isHtmlContent, cleanSingleLineField } from '@/utils/mpContentParser.js' + +export type ContentSegment = + | { type: 'text'; text: string } + | { type: 'mention'; userId: string; nickname: string; mentionDisplay: string } + | { + type: 'linkTag' + label: string + url: string + tagType: string + pagePath: string + tagId: string + appId?: string + mpKey?: string + passPhone?: boolean + phoneParamName?: string + } + | { type: 'link'; text: string; url: string } + | { type: 'image'; src: string; alt?: string } + | { type: 'video'; src: string } + | { type: 'heading'; level: number; text: string } + | { type: 'quote'; text: string } + | { type: 'table'; headers: string[]; rows: string[][] } + | { type: 'listItem'; ordered: boolean; number?: number; text: string; segs?: ContentSegment[] } diff --git a/reactH5/src/utils/h5Share.ts b/reactH5/src/utils/h5Share.ts new file mode 100644 index 00000000..a9c37d85 --- /dev/null +++ b/reactH5/src/utils/h5Share.ts @@ -0,0 +1,121 @@ +/** + * H5 分享能力(浏览器环境) + * 与微信小程序 onShareAppMessage / 朋友圈指引等彻底剥离;不依赖 wx.*。 + * + * 能力矩阵见:reactH5/docs/H5_SHARE.md + */ + +export type H5ShareToast = (message: string) => void + +export type BuildReadShareUrlOptions = { + /** 默认 window.location.origin */ + origin?: string + sectionId: string + sectionMid: number | null | undefined + /** 推荐码或用户 id,可为空 */ + referralRef?: string +} + +/** + * 当前站点下的阅读页可分享 URL(含 id/mid/ref) + */ +export function buildReadShareUrl(opts: BuildReadShareUrlOptions): string { + const origin = + opts.origin ?? (typeof window !== 'undefined' ? window.location.origin : '') + const mid = opts.sectionMid + const qs = + mid != null && mid !== undefined + ? `id=${encodeURIComponent(opts.sectionId)}&mid=${mid}` + : `id=${encodeURIComponent(opts.sectionId)}` + const ref = (opts.referralRef || '').trim() + const refQ = ref ? `&ref=${encodeURIComponent(ref)}` : '' + return `${origin}/read?${qs}${refQ}` +} + +export function canUseNavigatorShare(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.share === 'function' +} + +export type ShareOrCopyResult = 'shared' | 'copied' | 'fallback' + +/** + * 优先 Web Share API;不支持或用户取消后回退为复制链接。 + * @param cancelSilent 用户关闭系统分享面板时不弹 Toast + */ +export async function shareOrCopyPageUrl(params: { + title: string + text: string + url: string + showToast: H5ShareToast + /** 成功呼起分享且未抛错时(部分浏览器 share resolve 后才算成功) */ + sharedToast?: string + copyToast?: string + cancelSilent?: boolean +}): Promise { + const { + title, + text, + url, + showToast, + sharedToast = '已打开系统分享', + copyToast = '链接已复制', + cancelSilent = true, + } = params + + try { + if (canUseNavigatorShare()) { + await navigator.share({ title, text, url }) + showToast(sharedToast) + return 'shared' + } + } catch (e) { + const name = e && typeof e === 'object' && 'name' in e ? String((e as Error).name) : '' + if (name === 'AbortError' && cancelSilent) { + return 'shared' + } + } + + try { + await navigator.clipboard.writeText(url) + showToast(copyToast) + return 'copied' + } catch { + showToast(url) + return 'fallback' + } +} + +export async function copyTextWithToast( + text: string, + showToast: H5ShareToast, + okMessage = '已复制' +): Promise { + try { + await navigator.clipboard.writeText(text) + showToast(okMessage) + } catch { + showToast(text.length > 200 ? text.slice(0, 200) + '…' : text) + } +} + +/** + * 推广文案:标题摘要 + 配置尾注(含预览比例)+ 落地链接(H5 必须可点击打开,不用「搜小程序」表述) + */ +export function buildReadPromoClipboardBody(params: { + sectionTitle: string + previewPercent: number + footerTemplate: string + pageUrl: string +}): string { + const pct = params.previewPercent || 20 + const footer = String(params.footerTemplate || '') + .replace(/\{percent\}/g, String(pct)) + .replace(/\{url\}/g, params.pageUrl) + const title = String(params.sectionTitle || '').trim() + const core = title ? `${title}${footer}` : footer.replace(/^\s+/, '') + const url = params.pageUrl.trim() + if (url && !core.includes(url)) { + return `${core}\n\n${url}` + } + return core +} diff --git a/reactH5/src/utils/mpContentParser.js b/reactH5/src/utils/mpContentParser.js new file mode 100644 index 00000000..c64a351e --- /dev/null +++ b/reactH5/src/utils/mpContentParser.js @@ -0,0 +1,599 @@ +/** + * 卡若创业派对 - 内容解析工具 + * 解析 TipTap HTML 为阅读页可展示的 segments + * + * segment 类型: + * { type: 'text', text } + * { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead) + * { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap:外链→link-preview、小程序→navigateToMiniProgram) + * { type: 'link', text, url } — 普通超链接,点击外链预览(阅读页 onArticleLinkTap) + * { type: 'image', src, alt } — 图片 + * { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap /