feat: enhance login modal and avatar selection process
- Updated the login modal to include a user agreement section with clickable links to the user agreement and privacy policy. - Improved button styling and layout for better user experience. - Refactored avatar selection process to utilize local image paths, enhancing compatibility and reliability across different environments. - Added utility functions for resolving avatar file paths and selecting images from the gallery or camera. This update aims to streamline user interactions during login and avatar selection, ensuring a smoother experience.
This commit is contained in:
@@ -5,17 +5,7 @@
|
||||
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="login-title">登录 卡若创业派对</text>
|
||||
<text class="login-desc">{{desc}}</text>
|
||||
|
||||
<button id="agree-phone-btn" class="btn-login {{agreeProtocol ? '' : 'btn-login-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-login-hover">
|
||||
<text class="btn-login-icon">📱</text>
|
||||
<text>{{isLoggingIn ? '登录中...' : '手机号快捷登录'}}</text>
|
||||
</button>
|
||||
|
||||
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
|
||||
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
|
||||
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
|
||||
</view>
|
||||
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
|
||||
|
||||
<view class="login-agree-row" catchtap="onToggleAgree">
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
|
||||
<text class="agree-text">我已阅读并同意</text>
|
||||
@@ -23,5 +13,16 @@
|
||||
<text class="agree-text">和</text>
|
||||
<text class="agree-link" catchtap="onOpenPrivacy">《隐私政策》</text>
|
||||
</view>
|
||||
|
||||
<button id="agree-phone-btn" class="btn-login {{agreeProtocol ? '' : 'btn-login-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-login-hover">
|
||||
<icon class="btn-login-icon" name="smartphone" size="36" color="#ffffff"></icon>
|
||||
<text>{{isLoggingIn ? '登录中...' : '手机号快捷登录'}}</text>
|
||||
</button>
|
||||
|
||||
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
|
||||
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
|
||||
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
|
||||
</view>
|
||||
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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 类型非法 */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -18,19 +18,18 @@
|
||||
<text class="guide-desc guide-desc-sub" wx:if="{{fromNewUser}}">完成后即可继续使用小程序。</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器;新用户必填 -->
|
||||
<!-- 头像:相册/相机选图后上传(避免开发者工具 chooseAvatar + http://tmp 渲染报错) -->
|
||||
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
|
||||
<text class="form-label form-label-row" wx:if="{{fromNewUser}}">头像<text class="required-mark">*</text></text>
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<!-- 无头像时用不透明层遮住微信在 chooseAvatar 按钮内可能绘制的默认头像(非 data 回显) -->
|
||||
<view wx:if="{{!avatar}}" class="avatar-placeholder avatar-native-mask">?</view>
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 昵称:隐私授权由 app.js onNeedPrivacyAuthorization 走 wx.requirePrivacyAuthorize 原生流程 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<text class="tip-text">第 1 步:先设置对外展示的头像与昵称</text>
|
||||
</view>
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
|
||||
@@ -39,7 +39,7 @@
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section section-wizard">
|
||||
<view class="form-row">
|
||||
@@ -181,7 +181,7 @@
|
||||
</view>
|
||||
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
|
||||
@@ -189,7 +189,7 @@
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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,
|
||||
}
|
||||
|
||||
16
reactH5/.env.development.example
Normal file
16
reactH5/.env.development.example
Normal file
@@ -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 不参与浏览器请求,可保留或删除
|
||||
@@ -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 与小程序差异)
|
||||
|
||||
|
||||
27
reactH5/docs/H5_SHARE.md
Normal file
27
reactH5/docs/H5_SHARE.md
Normal file
@@ -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` 混写,以免破坏非微信环境。
|
||||
@@ -29,7 +29,7 @@
|
||||
| 登录 | 微信手机号组件 + 协议勾选 | 手机号 + `/h5/login-by-phone`;弹窗**强制协议勾选**与小程序流程对齐 |
|
||||
| 支付 | `wx.requestPayment` | 提示使用小程序;不下发真实支付 |
|
||||
| 一键收款 / 商户转账 | 微信 `requestMerchantTransfer` | 提示小程序完成 |
|
||||
| 分享 / 场景值 | 微信分享 | 未接 |
|
||||
| 分享 / 场景值 | 微信分享、`onShareAppMessage` | **H5 独立方案**:Web Share、`h5Share` 工具、`docs/H5_SHARE.md` |
|
||||
| 我的页子功能 | 跳转各页 | 已改为 **同路径壳页**(返回栈一致),非仅 Toast |
|
||||
|
||||
## 4. 样式 1:1 策略
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
reactH5/src/api/readExtras.ts
Normal file
69
reactH5/src/api/readExtras.ts
Normal file
@@ -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<string, unknown> & {
|
||||
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(/\/$/, '') }
|
||||
}
|
||||
@@ -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 类型非法 */
|
||||
|
||||
78
reactH5/src/components/LoginModal/LoginModal.form.css
Normal file
78
reactH5/src/components/LoginModal/LoginModal.form.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose} role="presentation">
|
||||
<div className="modal-content login-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
|
||||
<Icon name="x" size={36} color="#8e8e93" />
|
||||
</button>
|
||||
<div className="login-icon">
|
||||
<Icon name="lock" size={80} color="#00CED1" />
|
||||
</div>
|
||||
<div className="login-title">登录 卡若创业派对</div>
|
||||
<div className="login-desc">{desc}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-login ${agreeProtocol ? '' : 'btn-login-disabled'}`}
|
||||
disabled={!agreeProtocol}
|
||||
onClick={goLogin}
|
||||
>
|
||||
<span className="btn-login-icon">📱</span>
|
||||
<span>手机号登录</span>
|
||||
</button>
|
||||
{showCancel ? (
|
||||
<div className="login-modal-cancel" onClick={onClose} role="presentation">
|
||||
取消
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="login-agree-row"
|
||||
onClick={() => setAgreeProtocol((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setAgreeProtocol((v) => !v)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={`agree-checkbox ${agreeProtocol ? 'agree-checked' : ''}`}>
|
||||
{agreeProtocol ? <Icon name="check" size={24} color="#34C759" /> : null}
|
||||
</div>
|
||||
<span className="agree-text">我已阅读并同意</span>
|
||||
<span
|
||||
className="agree-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
nav('/agreement')
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
>
|
||||
《用户协议》
|
||||
</span>
|
||||
<span className="agree-text">和</span>
|
||||
<span
|
||||
className="agree-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
nav('/privacy')
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
>
|
||||
《隐私政策》
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
325
reactH5/src/components/PhoneAuthPanel/PhoneAuthPanel.tsx
Normal file
325
reactH5/src/components/PhoneAuthPanel/PhoneAuthPanel.tsx
Normal file
@@ -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<Panel>('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 (
|
||||
<div className="phone-auth-panel login-modal">
|
||||
<div className="login-icon">
|
||||
<Icon name="lock" size={80} color="#00CED1" />
|
||||
</div>
|
||||
<div className="login-title">{panel === 'login' ? '登录 卡若创业派对' : '注册 卡若创业派对'}</div>
|
||||
<div className="login-desc phone-auth-desc">{desc}</div>
|
||||
|
||||
<div
|
||||
className="login-agree-row"
|
||||
onClick={() => 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="同意用户协议与隐私政策"
|
||||
>
|
||||
<div className={`agree-checkbox ${agreeProtocol ? 'agree-checked' : ''}`} aria-hidden>
|
||||
{agreeProtocol ? <Icon name="check" size={24} color="#34C759" /> : null}
|
||||
</div>
|
||||
<span className="agree-text">我已阅读并同意</span>
|
||||
<span
|
||||
className="agree-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
nav('/agreement')
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
nav('/agreement')
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
《用户协议》
|
||||
</span>
|
||||
<span className="agree-text">和</span>
|
||||
<span
|
||||
className="agree-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
nav('/privacy')
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
nav('/privacy')
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
《隐私政策》
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="login-modal-fields">
|
||||
<div className="login-modal-field">
|
||||
<label className="login-modal-label" htmlFor="phone-auth-phone">
|
||||
手机号
|
||||
</label>
|
||||
<div className="login-modal-input-wrap">
|
||||
<input
|
||||
id="phone-auth-phone"
|
||||
className="login-modal-input"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
placeholder="11位手机号"
|
||||
maxLength={11}
|
||||
value={phone}
|
||||
onChange={(e) => setPhone((e.target.value || '').replace(/\D/g, '').slice(0, 11))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-modal-field">
|
||||
<label className="login-modal-label" htmlFor="phone-auth-pwd">
|
||||
{panel === 'register' ? '设置密码' : '密码'}
|
||||
</label>
|
||||
<div className="login-modal-input-wrap">
|
||||
<input
|
||||
id="phone-auth-pwd"
|
||||
className="login-modal-input"
|
||||
type="password"
|
||||
autoComplete={panel === 'register' ? 'new-password' : 'current-password'}
|
||||
placeholder="至少6位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panel === 'register' ? (
|
||||
<div className="login-modal-field">
|
||||
<label className="login-modal-label" htmlFor="phone-auth-pwd2">
|
||||
确认密码
|
||||
</label>
|
||||
<div className="login-modal-input-wrap">
|
||||
<input
|
||||
id="phone-auth-pwd2"
|
||||
className="login-modal-input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="再次输入密码"
|
||||
value={password2}
|
||||
onChange={(e) => setPassword2(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
{password2.length > 0 && password !== password2 ? (
|
||||
<p className="login-modal-field-hint">两次输入不一致</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{panel === 'login' ? (
|
||||
<button type="button" className="login-modal-forgot" onClick={goForgot}>
|
||||
忘记密码?
|
||||
</button>
|
||||
) : (
|
||||
<div className="login-modal-forgot-spacer" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panel === 'login' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-login ${canLogin ? '' : 'btn-login-disabled'}`}
|
||||
disabled={!canLogin}
|
||||
onClick={() => void doLogin()}
|
||||
>
|
||||
<span className="btn-login-icon">
|
||||
<Icon name="smartphone" size={36} color="#ffffff" />
|
||||
</span>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-login ${canRegister ? '' : 'btn-login-disabled'}`}
|
||||
disabled={!canRegister}
|
||||
onClick={() => void doRegister()}
|
||||
>
|
||||
<span className="btn-login-icon">
|
||||
<Icon name="smartphone" size={36} color="#ffffff" />
|
||||
</span>
|
||||
<span>{loading ? '注册中...' : '注册'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{panel === 'login' ? (
|
||||
<button type="button" className="login-modal-switch" onClick={switchToRegister}>
|
||||
没有账号?立即注册
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="login-modal-switch" onClick={switchToLogin}>
|
||||
已有账号?去登录
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showCancel && typeof onCancel === 'function' ? (
|
||||
<button type="button" className="login-modal-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
reactH5/src/components/PhoneAuthPanel/phone-auth-panel.css
Normal file
13
reactH5/src/components/PhoneAuthPanel/phone-auth-panel.css
Normal file
@@ -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;
|
||||
}
|
||||
4
reactH5/src/constants/h5Auth.ts
Normal file
4
reactH5/src/constants/h5Auth.ts
Normal file
@@ -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'
|
||||
@@ -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<Ctx | null>(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<Ctx | null>(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 <AppCtx.Provider value={v}>{children}</AppCtx.Provider>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="page">
|
||||
<div className="page phone-auth-page">
|
||||
<div className="nav-bar" style={{ paddingTop: statusBar }}>
|
||||
<button type="button" className="nav-back" onClick={() => nav(-1)} aria-label="返回">
|
||||
<button type="button" className="nav-back" onClick={onBack} aria-label="返回">
|
||||
<Icon name="chevron-left" size={44} color="rgba(255,255,255,0.6)" customClass="back-icon" />
|
||||
</button>
|
||||
<div className="nav-title">手机号登录</div>
|
||||
<div className="nav-title">登录 / 注册</div>
|
||||
<div className="nav-placeholder" />
|
||||
</div>
|
||||
<div style={{ height: statusBar + 44 }} />
|
||||
|
||||
<div className="content">
|
||||
<div className="tip-banner">
|
||||
<span className="tip-text">请输入已在平台绑定的 11 位手机号。老用户与小程序为同一账号(按库内手机号匹配)。若曾设置密码可填写,未设置可留空。</span>
|
||||
</div>
|
||||
|
||||
<div className="form-card">
|
||||
<div className="form-item">
|
||||
<div className="form-label">手机号</div>
|
||||
<div className="form-input-wrap">
|
||||
<input
|
||||
className="form-input-inner"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={account}
|
||||
onChange={(e) => setAccount((e.target.value || '').trim())}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<div className="form-label">密码(可留空)</div>
|
||||
<div className="form-input-wrap">
|
||||
<input
|
||||
className="form-input-inner"
|
||||
type="password"
|
||||
placeholder="可选,留空即可"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-primary ${!account || loading ? 'btn-disabled' : ''}`}
|
||||
disabled={!account || loading}
|
||||
onClick={async () => {
|
||||
if (!account || loading) return
|
||||
const phone = account.replace(/\s/g, '')
|
||||
if (phone.length < 11) {
|
||||
showToast('请输入11位手机号')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await request<{
|
||||
success: boolean
|
||||
data?: { user: MpUser; token: string }
|
||||
}>('/api/miniprogram/h5/login-by-phone', {
|
||||
method: 'POST',
|
||||
data: { phone, password: password || '' },
|
||||
})
|
||||
if (res.success && res.data) {
|
||||
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)
|
||||
showToast('登录成功')
|
||||
const cb = loc.state?.onSuccess
|
||||
if (typeof cb === 'function') cb()
|
||||
const to = resolveAfterLoginPath(loc.state?.returnTo)
|
||||
setTimeout(() => nav(to, { replace: true }), 400)
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="phone-auth-scroll">
|
||||
<PhoneAuthPanel desc={desc} forgotReturnTo={returnTo} onAuthenticated={afterAuth} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
121
reactH5/src/pages/ForgotPassword/ForgotPasswordPage.tsx
Normal file
121
reactH5/src/pages/ForgotPassword/ForgotPasswordPage.tsx
Normal file
@@ -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 (
|
||||
<div className="page">
|
||||
<div className="nav-bar" style={{ paddingTop: statusBar }}>
|
||||
<button type="button" className="nav-back" onClick={() => nav(-1)} aria-label="返回">
|
||||
<Icon name="chevron-left" size={44} color="rgba(255,255,255,0.6)" customClass="back-icon" />
|
||||
</button>
|
||||
<div className="nav-title">忘记密码</div>
|
||||
<div className="nav-placeholder" />
|
||||
</div>
|
||||
<div style={{ height: statusBar + 44 }} />
|
||||
|
||||
<div className="content">
|
||||
<div className="tip-banner">
|
||||
<span className="tip-text">
|
||||
将为此手机号重置 H5 登录密码(与小程序同一账号)。无需短信验证,请勿在公共网络使用;若未开启 H5 登录,请联系管理员。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-card">
|
||||
<div className="form-item">
|
||||
<div className="form-label">手机号</div>
|
||||
<div className="form-input-wrap">
|
||||
<input
|
||||
className="form-input-inner"
|
||||
type="tel"
|
||||
placeholder="请输入注册手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone((e.target.value || '').trim())}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<div className="form-label">新密码(至少6位)</div>
|
||||
<div className="form-input-wrap">
|
||||
<input
|
||||
className="form-input-inner"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<div className="form-label">确认新密码</div>
|
||||
<div className="form-input-wrap">
|
||||
<input
|
||||
className="form-input-inner"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
value={pwd2}
|
||||
onChange={(e) => setPwd2(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-primary ${loading ? 'btn-disabled' : ''}`}
|
||||
disabled={loading}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{loading ? '提交中...' : '重置密码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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, unknown>): 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<TipGift[]>([])
|
||||
const [micDefaultGiftId, setMicDefaultGiftId] = useState('')
|
||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||
const [loginModalDesc, setLoginModalDesc] = useState('登录后可参与打赏、上麦与链接嘉宾')
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState<null | { type: 'tip'; tipSource: string } | { type: 'link_karuo' }>(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 (
|
||||
<div className="page page-transition index-page">
|
||||
<div className="nav-placeholder" style={{ height: statusBarHeight + 44 }} />
|
||||
@@ -875,27 +939,6 @@ export function IndexPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoginModal
|
||||
show={showLoginModal}
|
||||
desc={loginModalDesc}
|
||||
showCancel
|
||||
onClose={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bottom-space" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<MpUser | null>(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() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LoginModal
|
||||
show={showLoginModal}
|
||||
desc="登录后可购买章节、解锁更多内容"
|
||||
showCancel
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowLoginModal(false)
|
||||
initUserStatus()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bottom-space" />
|
||||
</div>
|
||||
)
|
||||
|
||||
764
reactH5/src/pages/Read/ReadPage.css
Normal file
764
reactH5/src/pages/Read/ReadPage.css
Normal file
@@ -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;
|
||||
}
|
||||
1552
reactH5/src/pages/Read/ReadPage.tsx
Normal file
1552
reactH5/src/pages/Read/ReadPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
<Route path="/my" element={<MyPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<DevLoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/dev-login" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/agreement" element={<AgreementPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
@@ -44,7 +47,7 @@ export function AppRouter() {
|
||||
<Route path="/profile-edit" element={<ProfileEditP2 />} />
|
||||
<Route path="/member-detail" element={<MemberDetailP2 />} />
|
||||
|
||||
<Route path="/read" element={<P2SubPage title="阅读" hint="与小程序 read 阅读器对齐,开发中。" />} />
|
||||
<Route path="/read" element={<ReadPage />} />
|
||||
<Route path="/search" element={<P2SubPage title="搜索" />} />
|
||||
<Route path="/vip" element={<P2SubPage title="会员中心" />} />
|
||||
<Route path="/mentors" element={<P2SubPage title="导师顾问" />} />
|
||||
|
||||
80
reactH5/src/utils/chapterAccessH5.ts
Normal file
80
reactH5/src/utils/chapterAccessH5.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | null | undefined,
|
||||
opts: { userId?: string | null }
|
||||
): Promise<AccessState> {
|
||||
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<string, unknown>) => 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 {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
@@ -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[] }
|
||||
|
||||
121
reactH5/src/utils/h5Share.ts
Normal file
121
reactH5/src/utils/h5Share.ts
Normal file
@@ -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<ShareOrCopyResult> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
599
reactH5/src/utils/mpContentParser.js
Normal file
599
reactH5/src/utils/mpContentParser.js
Normal file
@@ -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 / <video>)
|
||||
*/
|
||||
|
||||
/** 判断内容是否为 HTML */
|
||||
function isHtmlContent(content) {
|
||||
if (!content || typeof content !== 'string') return false
|
||||
const trimmed = content.trim()
|
||||
return trimmed.includes('<') && trimmed.includes('>') && /<[a-z][^>]*>/i.test(trimmed)
|
||||
}
|
||||
|
||||
/** 解码常见 HTML 实体 */
|
||||
function decodeEntities(str) {
|
||||
return str
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文里 img 的 src:管理端常见为 /uploads/...(相对 API 根),浏览器会自动补全域名;
|
||||
* 小程序 <image> 不会拼接 baseUrl,需在此用 assetBase 转成可请求的绝对地址。
|
||||
* @param {string} src
|
||||
* @param {string} [assetBase] - 如 https://soulapi.example.com(无末尾 /)
|
||||
*/
|
||||
function resolveArticleImageSrc(src, assetBase) {
|
||||
if (!src || typeof src !== 'string') return src
|
||||
const s = src.trim()
|
||||
if (!s) return s
|
||||
if (/^(https?:|wxfile:|data:|blob:)/i.test(s)) return s
|
||||
if (s.startsWith('//')) return 'https:' + s
|
||||
if (s.startsWith('/')) {
|
||||
const base = String(assetBase || '').replace(/\/$/, '')
|
||||
if (!base) return s
|
||||
return base + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** 从 <img ...> 标签取出 src(双引号/单引号) */
|
||||
function extractImgSrcFromTag(tag) {
|
||||
const d = tag.match(/src\s*=\s*"([^"]*)"/i)
|
||||
if (d && d[1] != null) return d[1]
|
||||
const q = tag.match(/src\s*=\s*'([^']*)'/i)
|
||||
if (q && q[1] != null) return q[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 从 <video ...> 或包含 video 的 HTML 片段取出首个 src */
|
||||
function extractVideoSrcFromTag(tag) {
|
||||
const vm = tag.match(/<video[^>]*>/i)
|
||||
const chunk = vm ? vm[0] : tag
|
||||
return extractImgSrcFromTag(chunk)
|
||||
}
|
||||
|
||||
function pushResolvedVideoSrc(videos, rawSrc, config) {
|
||||
if (!rawSrc || typeof rawSrc !== 'string') return
|
||||
const decoded = decodeEntities(rawSrc.trim())
|
||||
if (!decoded) return
|
||||
const src =
|
||||
config && config.assetBase
|
||||
? resolveArticleImageSrc(decoded, config.assetBase)
|
||||
: resolveArticleImageSrc(decoded, '')
|
||||
videos.push({ src })
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
|
||||
*/
|
||||
function cleanSingleLineField(s) {
|
||||
if (!s && s !== 0) return ''
|
||||
let t = decodeEntities(String(s))
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
|
||||
.trim()
|
||||
return t
|
||||
}
|
||||
|
||||
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
|
||||
function cleanMentionNickname(n) {
|
||||
return cleanSingleLineField(n)
|
||||
}
|
||||
|
||||
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
|
||||
function stripTrailingAtForMention(before) {
|
||||
return before.replace(/[@@][\s\u00a0\u200b]*$/u, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个 HTML block 字符串解析为 segments 数组
|
||||
* 处理内联元素:mention / linkTag(span) / linkTag(a) / link(a) / img / video
|
||||
*/
|
||||
function parseBlockToSegments(block, config) {
|
||||
const segs = []
|
||||
const normalize = s => (s || '').trim().toLowerCase()
|
||||
const personTokenSet = new Set()
|
||||
for (const p of ((config && config.persons) || [])) {
|
||||
const token = normalize((p && p.token) || '')
|
||||
if (token) personTokenSet.add(token)
|
||||
}
|
||||
// 合并匹配所有内联元素
|
||||
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href=(?:"[^"]*"|'[^']*')[^>]*>[\s\S]*?<\/a>|<video[^>]*>[\s\S]*?<\/video>|<video[^>]*\/>|<img[^>]*\/?>/gi
|
||||
let lastEnd = 0
|
||||
let m
|
||||
|
||||
while ((m = tokenRe.exec(block)) !== null) {
|
||||
// 前置纯文本(mention 紧挨手写「找@」时去掉重复 @)
|
||||
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
|
||||
const tag = m[0]
|
||||
if (/data-type="mention"/i.test(tag)) {
|
||||
before = stripTrailingAtForMention(before)
|
||||
}
|
||||
if (before.trim()) segs.push({ type: 'text', text: before })
|
||||
|
||||
if (/data-type="mention"/i.test(tag)) {
|
||||
// @mention — TipTap mention span(span 内常见「@ 昵称」多空格,统一紧挨显示)
|
||||
const idMatch = tag.match(/data-id="([^"]*)"/)
|
||||
const labelMatch = tag.match(/data-label="([^"]*)"/)
|
||||
const innerText = tag.replace(/<[^>]+>/g, '')
|
||||
const userId = idMatch ? idMatch[1].trim() : ''
|
||||
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@@]\s*/, '')
|
||||
nickname = cleanMentionNickname((nickname || '').trim())
|
||||
const userExists = !!normalize(userId) && personTokenSet.has(normalize(userId))
|
||||
if (userExists && nickname) {
|
||||
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
|
||||
} else if (nickname) {
|
||||
// 被 @ 人物不存在时降级为普通文本,保持“静态 @某人”展示
|
||||
segs.push({ type: 'text', text: '@' + nickname })
|
||||
}
|
||||
|
||||
} else if (/data-type="linkTag"/i.test(tag)) {
|
||||
// #linkTag — 自定义 span 格式(data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="...")
|
||||
const urlMatch = tag.match(/data-url="([^"]*)"/)
|
||||
const tagTypeMatch = tag.match(/data-tag-type="([^"]*)"/)
|
||||
const pagePathMatch = tag.match(/data-page-path="([^"]*)"/)
|
||||
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
|
||||
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
|
||||
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
|
||||
const passPhoneMatch = tag.match(/data-pass-phone="([^"]*)"/)
|
||||
const phoneParamMatch = tag.match(/data-phone-param-name="([^"]*)"/)
|
||||
const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
|
||||
const url = urlMatch ? urlMatch[1] : ''
|
||||
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
|
||||
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
|
||||
const tagId = tagIdMatch ? tagIdMatch[1] : ''
|
||||
const appId = appIdMatch ? appIdMatch[1] : ''
|
||||
const mpKey = mpKeyMatch ? mpKeyMatch[1] : ''
|
||||
const passPhone = !!(passPhoneMatch && (passPhoneMatch[1] === '1' || /^true$/i.test(passPhoneMatch[1])))
|
||||
const phoneParamName = (phoneParamMatch && phoneParamMatch[1]) ? phoneParamMatch[1] : 'phone'
|
||||
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey, passPhone, phoneParamName })
|
||||
|
||||
} else if (/^<a /i.test(tag)) {
|
||||
// <a href>:兼容旧 #linkTag 与普通超链接
|
||||
const hrefMatch =
|
||||
tag.match(/href\s*=\s*"([^"]*)"/i) ||
|
||||
tag.match(/href\s*=\s*'([^']*)'/i)
|
||||
const url = hrefMatch ? decodeEntities((hrefMatch[1] || '').trim()) : ''
|
||||
const anchorText = decodeEntities((tag.replace(/<[^>]+>/g, '') || '').trim())
|
||||
|
||||
// 旧格式:#标签仍走 linkTag 逻辑,复用现有 onLinkTagTap
|
||||
if (/^[##]/u.test(anchorText)) {
|
||||
const label = cleanSingleLineField(anchorText.replace(/^[##]/u, ''))
|
||||
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
|
||||
} else {
|
||||
// 普通链接:展示锚文本;无锚文本则回退展示 url
|
||||
segs.push({ type: 'link', text: anchorText || url, url })
|
||||
}
|
||||
|
||||
} else if (/^<video/i.test(tag)) {
|
||||
const rawSrc = extractVideoSrcFromTag(tag)
|
||||
if (rawSrc) {
|
||||
const decoded = decodeEntities(rawSrc)
|
||||
const src =
|
||||
config && config.assetBase
|
||||
? resolveArticleImageSrc(decoded, config.assetBase)
|
||||
: resolveArticleImageSrc(decoded, '')
|
||||
segs.push({ type: 'video', src })
|
||||
}
|
||||
} else if (/^<img /i.test(tag)) {
|
||||
// 图片(src 可能为相对路径,需结合 config.assetBase)
|
||||
const rawSrc = extractImgSrcFromTag(tag)
|
||||
const altMatch = tag.match(/alt\s*=\s*"([^"]*)"/i) || tag.match(/alt\s*=\s*'([^']*)'/i)
|
||||
if (rawSrc) {
|
||||
const decoded = decodeEntities(rawSrc)
|
||||
const src =
|
||||
config && config.assetBase
|
||||
? resolveArticleImageSrc(decoded, config.assetBase)
|
||||
: resolveArticleImageSrc(decoded, '')
|
||||
segs.push({ type: 'image', src, alt: altMatch ? decodeEntities(altMatch[1]) : '' })
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = m.index + tag.length
|
||||
}
|
||||
|
||||
// 尾部纯文本
|
||||
const after = decodeEntities(block.slice(lastEnd).replace(/<[^>]+>/g, ''))
|
||||
if (after.trim()) segs.push({ type: 'text', text: after })
|
||||
|
||||
return segs
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 <table> HTML 为结构化数据 { headers: string[], rows: string[][] }
|
||||
*/
|
||||
function parseTableHtml(tableHtml) {
|
||||
const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim())
|
||||
const headers = []
|
||||
const rows = []
|
||||
|
||||
const headMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i)
|
||||
if (headMatch) {
|
||||
const thRe = /<th[^>]*>([\s\S]*?)<\/th>/gi
|
||||
let m
|
||||
while ((m = thRe.exec(headMatch[1])) !== null) {
|
||||
headers.push(stripTags(m[1]))
|
||||
}
|
||||
}
|
||||
|
||||
const bodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
|
||||
const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml
|
||||
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
||||
let trM
|
||||
let isFirst = !headMatch
|
||||
while ((trM = trRe.exec(bodyContent)) !== null) {
|
||||
if (headMatch && trM[0] === (headMatch[0].match(/<tr[^>]*>[\s\S]*?<\/tr>/i) || [])[0]) continue
|
||||
const cells = []
|
||||
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
|
||||
let cM
|
||||
while ((cM = cellRe.exec(trM[1])) !== null) {
|
||||
cells.push(stripTags(cM[1]))
|
||||
}
|
||||
if (!cells.length) continue
|
||||
if (isFirst && !headers.length) {
|
||||
headers.push(...cells)
|
||||
isFirst = false
|
||||
} else {
|
||||
rows.push(cells)
|
||||
}
|
||||
}
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 中解析出 lines(纯文本行)和 segments(含富文本片段)
|
||||
* segment 新增类型:heading / quote / listItem
|
||||
* @param {string} html
|
||||
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string }
|
||||
*/
|
||||
function parseHtmlToSegments(html, config) {
|
||||
const lines = []
|
||||
const segments = []
|
||||
|
||||
// 0. 提取 <table> 块
|
||||
const tables = []
|
||||
let text = html.replace(/<table[^>]*>[\s\S]*?<\/table>/gi, (match) => {
|
||||
const idx = tables.length
|
||||
tables.push(parseTableHtml(match))
|
||||
return '\n__TABLE_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 0.5 管理端 TipTap:div.rich-video-wrap > video +(可选)caption,须在剥离 div 前整体替换
|
||||
const videos = []
|
||||
const videoWrapRe =
|
||||
/<div[^>]*(?:class="[^"]*rich-video-wrap[^"]*"|class='[^']*rich-video-wrap[^']*')[^>]*>\s*<video[^>]*>\s*<\/video>\s*(?:<div[^>]*(?:class="[^"]*rich-video-caption[^"]*"|class='[^']*rich-video-caption[^']*')[^>]*>[\s\S]*?<\/div>)?\s*<\/div>/gi
|
||||
text = text.replace(videoWrapRe, (match) => {
|
||||
const rawSrc = extractVideoSrcFromTag(match)
|
||||
if (!rawSrc) return match
|
||||
const idx = videos.length
|
||||
pushResolvedVideoSrc(videos, rawSrc, config)
|
||||
return '\n__VIDEO_' + idx + '__\n'
|
||||
})
|
||||
// 未包在 rich-video-wrap 内的裸 <video>(兼容粘贴或其它导出)
|
||||
text = text.replace(/<video[^>]*\/>/gi, (match) => {
|
||||
const rawSrc = extractVideoSrcFromTag(match)
|
||||
if (!rawSrc) return match
|
||||
const idx = videos.length
|
||||
pushResolvedVideoSrc(videos, rawSrc, config)
|
||||
return '\n__VIDEO_' + idx + '__\n'
|
||||
})
|
||||
text = text.replace(/<video[^>]*>\s*<\/video>/gi, (match) => {
|
||||
const rawSrc = extractVideoSrcFromTag(match)
|
||||
if (!rawSrc) return match
|
||||
const idx = videos.length
|
||||
pushResolvedVideoSrc(videos, rawSrc, config)
|
||||
return '\n__VIDEO_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 1. 提取 <h2>/<h3> → heading 占位
|
||||
const headings = []
|
||||
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
|
||||
const idx = headings.length
|
||||
headings.push({ level: parseInt(lvl, 10), raw: inner.trim() })
|
||||
return '\n__H_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 2. 提取 <blockquote> → quote 占位
|
||||
const quotes = []
|
||||
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) {
|
||||
const idx = quotes.length
|
||||
quotes.push(inner.trim())
|
||||
return '\n__Q_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 3. 列表 → listItem 占位(保留 ordered 标记)
|
||||
var olDepth = 0
|
||||
var olCounter = 0
|
||||
text = text.replace(/<ol[^>]*>/gi, function () { olDepth++; olCounter = 0; return '\n' })
|
||||
text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' })
|
||||
text = text.replace(/<ul[^>]*>/gi, '\n')
|
||||
text = text.replace(/<\/ul>/gi, '\n')
|
||||
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
|
||||
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
|
||||
if (!cleaned) return '\n'
|
||||
if (olDepth > 0) {
|
||||
olCounter++
|
||||
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
|
||||
}
|
||||
return '\n__LI_U__ ' + cleaned + '\n'
|
||||
})
|
||||
|
||||
// 4. 块级标签换行
|
||||
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
||||
text = text.replace(/<p[^>]*>/gi, '')
|
||||
text = text.replace(/<\/p>/gi, '\n')
|
||||
text = text.replace(/<div[^>]*>/gi, '')
|
||||
text = text.replace(/<\/div>/gi, '\n')
|
||||
text = text.replace(/<br\s*\/?>/gi, '\n')
|
||||
text = text.replace(/<hr\s*\/?>/gi, '\n')
|
||||
|
||||
// 5. 逐段解析
|
||||
var blocks = text.split(/\n+/)
|
||||
for (var bi = 0; bi < blocks.length; bi++) {
|
||||
var block = blocks[bi]
|
||||
if (!block.trim()) continue
|
||||
|
||||
// table
|
||||
var tblM = block.trim().match(/^__TABLE_(\d+)__$/)
|
||||
if (tblM) {
|
||||
var tbl = tables[parseInt(tblM[1], 10)]
|
||||
if (tbl) {
|
||||
lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n'))
|
||||
segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// video(整块占位)
|
||||
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
|
||||
if (vidM) {
|
||||
var v = videos[parseInt(vidM[1], 10)]
|
||||
if (v && v.src) {
|
||||
lines.push('')
|
||||
segments.push([{ type: 'video', src: v.src }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// heading
|
||||
var hM = block.trim().match(/^__H_(\d+)__$/)
|
||||
if (hM) {
|
||||
var h = headings[parseInt(hM[1], 10)]
|
||||
if (h) {
|
||||
var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(hText)
|
||||
segments.push([{ type: 'heading', level: h.level, text: hText }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// quote
|
||||
var qM = block.trim().match(/^__Q_(\d+)__$/)
|
||||
if (qM) {
|
||||
var qHtml = quotes[parseInt(qM[1], 10)]
|
||||
if (qHtml) {
|
||||
var qText = decodeEntities(
|
||||
qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim()
|
||||
).replace(/\n{2,}/g, '\n')
|
||||
lines.push(qText)
|
||||
segments.push([{ type: 'quote', text: qText }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ordered list item
|
||||
var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/)
|
||||
if (liOM) {
|
||||
var liNum = parseInt(liOM[1], 10)
|
||||
var liContent = liOM[2].trim()
|
||||
var liSegs = parseBlockToSegments(liContent, config)
|
||||
var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(liNum + '. ' + liText)
|
||||
segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }])
|
||||
continue
|
||||
}
|
||||
|
||||
// unordered list item
|
||||
var liUM = block.match(/^__LI_U__\s*(.*)/)
|
||||
if (liUM) {
|
||||
var liContent2 = liUM[1].trim()
|
||||
var liSegs2 = parseBlockToSegments(liContent2, config)
|
||||
var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(liText2)
|
||||
segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }])
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
var blockSegs = parseBlockToSegments(block, config)
|
||||
if (!blockSegs.length) continue
|
||||
|
||||
if (
|
||||
blockSegs.length === 1 &&
|
||||
(blockSegs[0].type === 'image' || blockSegs[0].type === 'video')
|
||||
) {
|
||||
lines.push('')
|
||||
segments.push(blockSegs)
|
||||
continue
|
||||
}
|
||||
|
||||
if (config && (config.persons?.length || config.linkTags?.length)) {
|
||||
var expanded = []
|
||||
for (var si = 0; si < blockSegs.length; si++) {
|
||||
var seg = blockSegs[si]
|
||||
if (seg.type === 'text' && seg.text) {
|
||||
expanded.push.apply(expanded, matchLineToSegments(seg.text, config))
|
||||
} else {
|
||||
expanded.push(seg)
|
||||
}
|
||||
}
|
||||
blockSegs = expanded
|
||||
}
|
||||
|
||||
var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
|
||||
lines.push(lineText)
|
||||
segments.push(blockSegs)
|
||||
}
|
||||
|
||||
return { lines, segments }
|
||||
}
|
||||
|
||||
/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
|
||||
function stripMarkdownFormatting(text) {
|
||||
if (!text) return text
|
||||
let s = text
|
||||
s = s.replace(/^#{1,6}\s+/gm, '')
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
s = s.replace(/__(.+?)__/g, '$1')
|
||||
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
|
||||
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1')
|
||||
s = s.replace(/~~(.+?)~~/g, '$1')
|
||||
s = s.replace(/`([^`]+)`/g, '$1')
|
||||
s = s.replace(/^>\s+/gm, '')
|
||||
s = s.replace(/^---$/gm, '')
|
||||
s = s.replace(/^\* /gm, '• ')
|
||||
s = s.replace(/^- /gm, '• ')
|
||||
s = s.replace(/^\d+\.\s/gm, '')
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
|
||||
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
|
||||
* 点击加好友时须传 persons.token(与 CKB lead 的 targetUserId 一致),不能用 personId。
|
||||
*/
|
||||
function matchLineToSegments(line, config) {
|
||||
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
|
||||
return [{ type: 'text', text: line }]
|
||||
}
|
||||
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
|
||||
line = line.replace(/([@@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
|
||||
const normalize = s => (s || '').trim().toLowerCase()
|
||||
const personMap = {}
|
||||
const tagMap = {}
|
||||
for (const p of (config.persons || [])) {
|
||||
const token = (p.token || '').trim()
|
||||
if (!token) continue
|
||||
const display = (p.name || p.label || '').trim()
|
||||
const aliasStr = p.aliases != null ? String(p.aliases) : ''
|
||||
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
|
||||
.map((x) => (x != null ? String(x) : '').trim())
|
||||
.filter(Boolean)
|
||||
.map(normalize)
|
||||
.filter(Boolean)
|
||||
for (const k of keys) {
|
||||
if (!personMap[k]) personMap[k] = p
|
||||
}
|
||||
}
|
||||
for (const t of (config.linkTags || [])) {
|
||||
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
|
||||
for (const k of keys) { if (!tagMap[k]) tagMap[k] = t }
|
||||
}
|
||||
const esc = n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const personNames = Object.keys(personMap).sort((a, b) => b.length - a.length).map(esc)
|
||||
const tagLabels = Object.keys(tagMap).sort((a, b) => b.length - a.length).map(esc)
|
||||
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
|
||||
|
||||
const parts = []
|
||||
if (personNames.length) parts.push('[@@]\\s*(' + personNames.join('|') + ')')
|
||||
if (tagLabels.length) parts.push('[##]\\s*(' + tagLabels.join('|') + ')')
|
||||
const pattern = new RegExp(parts.join('|'), 'gi')
|
||||
|
||||
const segs = []
|
||||
let lastEnd = 0
|
||||
let m
|
||||
while ((m = pattern.exec(line)) !== null) {
|
||||
if (m.index > lastEnd) {
|
||||
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
|
||||
}
|
||||
const full = m[0]
|
||||
if (/^[@@]/u.test(full)) {
|
||||
const body = full.replace(/^[@@]\s*/u, '')
|
||||
const person = personMap[normalize(body)]
|
||||
if (person) {
|
||||
const nick = cleanSingleLineField(person.name || person.label || body)
|
||||
const uid = (person.token || '').trim()
|
||||
if (uid) {
|
||||
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
|
||||
} else {
|
||||
segs.push({ type: 'text', text: full })
|
||||
}
|
||||
} else {
|
||||
segs.push({ type: 'text', text: full })
|
||||
}
|
||||
} else {
|
||||
const body = full.replace(/^[##]\s*/u, '')
|
||||
const tag = tagMap[normalize(body)]
|
||||
if (tag) {
|
||||
segs.push({
|
||||
type: 'linkTag',
|
||||
label: tag.label || body,
|
||||
url: tag.url || '',
|
||||
tagType: tag.type || 'url',
|
||||
pagePath: tag.pagePath || '',
|
||||
tagId: tag.tagId || '',
|
||||
appId: tag.appId || '',
|
||||
mpKey: tag.mpKey || '',
|
||||
passPhone: !!tag.passPhone,
|
||||
phoneParamName: tag.phoneParamName || 'phone'
|
||||
})
|
||||
} else {
|
||||
segs.push({ type: 'text', text: full })
|
||||
}
|
||||
}
|
||||
lastEnd = m.index + full.length
|
||||
}
|
||||
if (lastEnd < line.length) {
|
||||
segs.push({ type: 'text', text: line.slice(lastEnd) })
|
||||
}
|
||||
return segs.length ? segs : [{ type: 'text', text: line }]
|
||||
}
|
||||
|
||||
/** 纯文本/Markdown 按行解析 */
|
||||
function parsePlainTextToSegments(text, config) {
|
||||
const cleaned = stripMarkdownFormatting(text)
|
||||
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
||||
const segments = lines.map(line => matchLineToSegments(line, config))
|
||||
return { lines, segments }
|
||||
}
|
||||
|
||||
/** 清理残留的 Markdown 图片引用文本(如 "image.png" ) */
|
||||
function stripOrphanImageRefs(text) {
|
||||
if (!text) return text
|
||||
text = text.replace(/[^\s]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp)!\[[^\]]*\]\([^)]*\)/gi, '')
|
||||
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 将原始内容解析为 contentSegments(用于阅读页展示)
|
||||
* @param {string} rawContent
|
||||
* @param {object} [config] - { persons: [], linkTags: [] }
|
||||
* @returns {{ lines: string[], segments: Array<Array<segment>> }}
|
||||
*/
|
||||
function parseContent(rawContent, config) {
|
||||
if (!rawContent || typeof rawContent !== 'string') {
|
||||
return { lines: [], segments: [] }
|
||||
}
|
||||
let content = stripOrphanImageRefs(rawContent)
|
||||
if (isHtmlContent(content)) {
|
||||
return parseHtmlToSegments(content, config)
|
||||
}
|
||||
return parsePlainTextToSegments(content, config)
|
||||
}
|
||||
|
||||
export { parseContent, isHtmlContent, cleanSingleLineField }
|
||||
143
reactH5/src/utils/readMarketingRuleH5.ts
Normal file
143
reactH5/src/utils/readMarketingRuleH5.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
export type Pay365RuleUi = {
|
||||
ruleId: string
|
||||
serverRuleId?: number
|
||||
title: string
|
||||
message: string
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
action: 'pay365' | 'navigate'
|
||||
target: string | null
|
||||
amount: number | null
|
||||
revealMode: 'modal' | 'anchor'
|
||||
anchorLabel: string
|
||||
}
|
||||
|
||||
function trimStr(v: unknown): string {
|
||||
if (v == null) return ''
|
||||
return String(v).trim()
|
||||
}
|
||||
|
||||
function findRuleByTriggerCondition(rules: Array<Record<string, unknown>>, conditionKey: string) {
|
||||
return rules.find((r) => {
|
||||
if (r.completed) return false
|
||||
const tc = r.triggerConditions
|
||||
if (Array.isArray(tc)) return tc.includes(conditionKey)
|
||||
if (typeof tc === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(tc) as unknown
|
||||
if (Array.isArray(parsed)) return parsed.includes(conditionKey)
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function parseActionConfig(rule: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
if (!rule) return {}
|
||||
const ac = rule.actionConfig
|
||||
if (ac && typeof ac === 'object' && !Array.isArray(ac)) return ac as Record<string, unknown>
|
||||
if (typeof ac === 'string' && ac.trim()) {
|
||||
try {
|
||||
const p = JSON.parse(ac) as unknown
|
||||
if (p && typeof p === 'object') return p as Record<string, unknown>
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
let rulesCache: Record<string, unknown>[] | null = null
|
||||
let cacheTs = 0
|
||||
const CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
export async function loadUserRules(userId: string, force = false): Promise<Record<string, unknown>[]> {
|
||||
const now = Date.now()
|
||||
if (!force && rulesCache && now - cacheTs < CACHE_TTL) return rulesCache
|
||||
try {
|
||||
const url = userId.trim()
|
||||
? `/api/miniprogram/user-rules?userId=${encodeURIComponent(userId)}`
|
||||
: '/api/miniprogram/user-rules'
|
||||
const res = await request<{ success?: boolean; rules?: Record<string, unknown>[] }>({
|
||||
url,
|
||||
method: 'GET',
|
||||
silent: true,
|
||||
})
|
||||
if (res?.success && Array.isArray(res.rules)) {
|
||||
rulesCache = res.rules
|
||||
cacheTs = now
|
||||
return res.rules
|
||||
}
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
return rulesCache || []
|
||||
}
|
||||
|
||||
export async function getChapterReadMarketingRule(
|
||||
userId: string,
|
||||
ctx: {
|
||||
hasFullBook: boolean
|
||||
isVip: boolean
|
||||
getReadCount: () => number
|
||||
getBrowseDistinctChapterCount: () => number
|
||||
}
|
||||
): Promise<Pay365RuleUi | null> {
|
||||
if (!userId.trim()) return null
|
||||
if (ctx.hasFullBook || ctx.isVip) return null
|
||||
|
||||
const rules = await loadUserRules(userId)
|
||||
const rule =
|
||||
findRuleByTriggerCondition(rules as Array<Record<string, unknown>>, 'view_n_chapters_pay365') ||
|
||||
findRuleByTriggerCondition(rules as Array<Record<string, unknown>>, 'browse_n_chapters')
|
||||
if (!rule) return null
|
||||
|
||||
const ac = parseActionConfig(rule)
|
||||
const threshold = Number(ac.chapterThreshold) > 0 ? Number(ac.chapterThreshold) : 3
|
||||
const fullRead = ctx.getReadCount()
|
||||
const browseDistinct = ctx.getBrowseDistinctChapterCount()
|
||||
const readCount = Math.max(Number(fullRead) || 0, Number(browseDistinct) || 0)
|
||||
if (readCount < threshold) return null
|
||||
|
||||
const ctaKind = String(ac.ctaKind || 'pay365')
|
||||
const amount = ctaKind === 'pay365' ? (Number(ac.amount) > 0 ? Number(ac.amount) : 365) : null
|
||||
const rm = ac.revealMode
|
||||
let revealMode: 'modal' | 'anchor' = rm === 'modal' || rm === 'anchor' ? (rm as 'modal' | 'anchor') : 'modal'
|
||||
if (!revealMode) revealMode = 'modal'
|
||||
const anchorLabel = trimStr(ac.anchorLabel)
|
||||
|
||||
return {
|
||||
ruleId: 'view_n_pay365',
|
||||
serverRuleId:
|
||||
typeof rule.id === 'number' ? rule.id : typeof rule.id === 'string' ? parseInt(String(rule.id), 10) : undefined,
|
||||
title: trimStr(rule.title) || '加入 365 读书会',
|
||||
message:
|
||||
trimStr(rule.description) ||
|
||||
`你已阅读 ${readCount} 个章节,加入 365 读书会可解锁全部内容,一年内不限次阅读。`,
|
||||
confirmText: amount ? `¥${amount} 立即加入` : '去了解',
|
||||
cancelText: '再看看',
|
||||
action: ctaKind === 'pay365' ? 'pay365' : 'navigate',
|
||||
target: null,
|
||||
amount,
|
||||
revealMode,
|
||||
anchorLabel: anchorLabel || '加入 365 读书会,解锁全部内容',
|
||||
}
|
||||
}
|
||||
|
||||
export async function markRuleCompletedOnServer(userId: string, ruleId: number | null | undefined) {
|
||||
if (!userId || !ruleId || !Number.isFinite(ruleId)) return
|
||||
try {
|
||||
await request({
|
||||
url: '/api/miniprogram/user-rules/complete',
|
||||
method: 'POST',
|
||||
data: { userId, ruleId },
|
||||
silent: true,
|
||||
})
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
71
reactH5/src/utils/readStorage.ts
Normal file
71
reactH5/src/utils/readStorage.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
const RECENT_OPENS_KEY = 'recent_section_opens'
|
||||
const READ_IDS_KEY = 'readSectionIds'
|
||||
|
||||
type RecentOpen = { id: string; t: number }
|
||||
|
||||
export function touchRecentSection(sectionId: string) {
|
||||
if (!sectionId) return
|
||||
try {
|
||||
let list: RecentOpen[] = []
|
||||
const raw = localStorage.getItem(RECENT_OPENS_KEY)
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(p)) list = p as RecentOpen[]
|
||||
}
|
||||
const now = Date.now()
|
||||
list = list.filter((x) => x && String(x.id) !== String(sectionId))
|
||||
list.unshift({ id: String(sectionId), t: now })
|
||||
localStorage.setItem(RECENT_OPENS_KEY, JSON.stringify(list.slice(0, 40)))
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
export function getBrowseDistinctChapterCount(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_OPENS_KEY)
|
||||
if (!raw) return 0
|
||||
const list = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(list)) return 0
|
||||
const ids = new Set<string>()
|
||||
for (const x of list) {
|
||||
if (x && typeof x === 'object' && (x as RecentOpen).id) {
|
||||
ids.add(String((x as RecentOpen).id))
|
||||
}
|
||||
}
|
||||
return ids.size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 对齐小程序「已读章节」列表(仅存 localStorage) */
|
||||
export function persistMarkSectionRead(sectionId: string) {
|
||||
if (!sectionId) return loadReadSectionIds()
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_IDS_KEY)
|
||||
let ids: string[] = []
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(p)) ids = p as string[]
|
||||
}
|
||||
if (!ids.includes(sectionId)) {
|
||||
ids = [...ids, sectionId]
|
||||
localStorage.setItem(READ_IDS_KEY, JSON.stringify(ids))
|
||||
}
|
||||
return ids
|
||||
} catch {
|
||||
return loadReadSectionIds()
|
||||
}
|
||||
}
|
||||
|
||||
export function loadReadSectionIds(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_IDS_KEY)
|
||||
if (!raw) return []
|
||||
const p = JSON.parse(raw) as unknown
|
||||
return Array.isArray(p) ? (p as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
148
reactH5/src/utils/readingTrackerH5.ts
Normal file
148
reactH5/src/utils/readingTrackerH5.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
type Tracker = {
|
||||
sectionId: string
|
||||
startTime: number
|
||||
lastScrollTime: number
|
||||
totalDuration: number
|
||||
maxProgress: number
|
||||
lastPosition: number
|
||||
isCompleted: boolean
|
||||
completedAt: number | null
|
||||
}
|
||||
|
||||
const LS_KEY = 'reading_progress'
|
||||
|
||||
type ProgressRow = { progress: number; lastPosition: number; lastOpenAt: number }
|
||||
|
||||
function loadMap(): Record<string, ProgressRow> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (!raw) return {}
|
||||
const p = JSON.parse(raw) as unknown
|
||||
return p && typeof p === 'object' ? (p as Record<string, ProgressRow>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocal(sectionId: string, progress: number, lastPosition: number) {
|
||||
try {
|
||||
const m = loadMap()
|
||||
m[sectionId] = { progress, lastPosition, lastOpenAt: Date.now() }
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(m))
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
class ReadingTrackerH5 {
|
||||
active: Tracker | null = null
|
||||
reportInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
init(sectionId: string, userId: string | null, onRestoreScroll?: (top: number) => void) {
|
||||
this.cleanup()
|
||||
this.active = {
|
||||
sectionId,
|
||||
startTime: Date.now(),
|
||||
lastScrollTime: Date.now(),
|
||||
totalDuration: 0,
|
||||
maxProgress: 0,
|
||||
lastPosition: 0,
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
}
|
||||
saveLocal(sectionId, 0, 0)
|
||||
void this.reportToServer(userId, false)
|
||||
|
||||
const m = loadMap()[sectionId]
|
||||
if (m && m.lastPosition > 100 && onRestoreScroll) {
|
||||
setTimeout(() => onRestoreScroll(m.lastPosition), 400)
|
||||
}
|
||||
|
||||
this.reportInterval = setInterval(() => {
|
||||
void this.reportToServer(userId, false)
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
updateProgress(
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
userId: string | null,
|
||||
onProgressPct?: (n: number) => void
|
||||
) {
|
||||
if (!this.active) return
|
||||
const totalScrollable = scrollHeight - clientHeight
|
||||
if (totalScrollable <= 0) return
|
||||
const progress = Math.min(100, Math.round((scrollTop / totalScrollable) * 100))
|
||||
if (progress > this.active.maxProgress) {
|
||||
this.active.maxProgress = progress
|
||||
this.active.lastPosition = scrollTop
|
||||
saveLocal(this.active.sectionId, progress, scrollTop)
|
||||
onProgressPct?.(progress)
|
||||
}
|
||||
if (progress >= 90 && !this.active.isCompleted) {
|
||||
void this.checkCompletion(userId)
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCompletion(userId: string | null) {
|
||||
if (!this.active || this.active.isCompleted) return
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
if (this.active && this.active.maxProgress >= 90 && !this.active.isCompleted) {
|
||||
this.active.isCompleted = true
|
||||
this.active.completedAt = Date.now()
|
||||
await this.reportToServer(userId, true)
|
||||
}
|
||||
}
|
||||
|
||||
private async reportToServer(userId: string | null, isCompletion: boolean) {
|
||||
if (!this.active) return
|
||||
const uid = (userId || '').trim()
|
||||
if (!uid) return
|
||||
|
||||
const now = Date.now()
|
||||
const duration = Math.round((now - this.active.lastScrollTime) / 1000)
|
||||
this.active.totalDuration += duration
|
||||
this.active.lastScrollTime = now
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
userId: uid,
|
||||
sectionId: this.active.sectionId,
|
||||
progress: this.active.maxProgress,
|
||||
duration: this.active.totalDuration,
|
||||
status: this.active.isCompleted ? 'completed' : 'reading',
|
||||
}
|
||||
if (this.active.isCompleted && this.active.completedAt != null) {
|
||||
data.completedAt = new Date(this.active.completedAt).toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: '/api/miniprogram/user/reading-progress',
|
||||
method: 'POST',
|
||||
data,
|
||||
silent: true,
|
||||
})
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (isCompletion) {
|
||||
/* toast 由页面处理 */
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(userId?: string | null) {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval)
|
||||
this.reportInterval = null
|
||||
}
|
||||
if (this.active) {
|
||||
void this.reportToServer(userId ?? null, false)
|
||||
this.active = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readingTrackerH5 = new ReadingTrackerH5()
|
||||
33
reactH5/src/utils/sceneCompat.ts
Normal file
33
reactH5/src/utils/sceneCompat.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** 对齐 miniprogram utils/scene.parseScene */
|
||||
|
||||
const SEP = '_'
|
||||
|
||||
export function parseScene(sceneStr: string | null | undefined): {
|
||||
mid: number
|
||||
id: string
|
||||
ref: string
|
||||
} {
|
||||
const res = { mid: 0, id: '', ref: '' }
|
||||
if (!sceneStr || typeof sceneStr !== 'string') return res
|
||||
const decoded = decodeURIComponent(String(sceneStr)).trim()
|
||||
const parts = decoded.split(/[&_]/)
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf('=')
|
||||
if (eq > 0) {
|
||||
const k = part.slice(0, eq)
|
||||
const v = part.slice(eq + 1)
|
||||
if (k === 'mid') res.mid = parseInt(v, 10) || 0
|
||||
if (k === 'id' && v) res.id = v
|
||||
if (k === 'ref' && v) res.ref = v
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export function buildScene(opts: { mid?: number; id?: string; ref?: string }): string {
|
||||
const parts: string[] = []
|
||||
if (opts.mid != null && `${opts.mid}`.trim() !== '') parts.push(`mid=${opts.mid}`)
|
||||
if (opts.id) parts.push(`id=${opts.id}`)
|
||||
if (opts.ref) parts.push(`ref=${opts.ref}`)
|
||||
return parts.join(SEP)
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://soulapi.quwanzhi.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
// 默认走本机 soul-api :9100,避免未配 .env 时误连线上 release 导致 h5/login 403
|
||||
const proxyTarget =
|
||||
(env.VITE_PROXY_TARGET || '').trim() || 'http://127.0.0.1:9100'
|
||||
const secure = proxyTarget.startsWith('https')
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
secure,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -83,6 +83,10 @@ func (c *Config) BaseURLJoin(path string) string {
|
||||
|
||||
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
|
||||
var defaultCORSOrigins = []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:4173",
|
||||
"http://127.0.0.1:4173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://soul.quwanzhi.com",
|
||||
|
||||
@@ -63,6 +63,7 @@ func Init(dsn string) error {
|
||||
}
|
||||
ensureOpenPlatformTablesRaw(db)
|
||||
ensureUserDiscPdpColumns(db)
|
||||
ensureUserPasswordHashColumn(db)
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
@@ -152,6 +153,7 @@ func Init(dsn string) error {
|
||||
}
|
||||
ensureOpenPlatformTablesRaw(db)
|
||||
ensureUserDiscPdpColumns(db)
|
||||
ensureUserPasswordHashColumn(db)
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
@@ -171,6 +173,16 @@ func ensureUserDiscPdpColumns(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// ensureUserPasswordHashColumn users 表补充 password_hash(SKIP_AUTO_MIGRATE 等场景)
|
||||
func ensureUserPasswordHashColumn(db *gorm.DB) {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&model.User{}, "PasswordHash") {
|
||||
if err := db.Exec("ALTER TABLE users ADD COLUMN password_hash VARCHAR(128) NULL COMMENT 'H5登录 bcrypt'").Error; err != nil {
|
||||
log.Printf("database: users add password_hash column warning: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DB 返回全局 *gorm.DB,仅在 Init 成功后调用
|
||||
func DB() *gorm.DB {
|
||||
return db
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -275,11 +277,32 @@ func MiniprogramDevLoginAs(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// saveH5UserPasswordHash 用原生 SQL 更新 users.password_hash,避免 GORM Model+Updates 在部分库/版本下更新失败。
|
||||
func saveH5UserPasswordHash(db *gorm.DB, userID, bcryptHash string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("empty user id")
|
||||
}
|
||||
at := time.Now()
|
||||
r := db.Exec("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", bcryptHash, at, userID)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected < 1 {
|
||||
return fmt.Errorf("no rows updated for id=%s", userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// miniprogramPhoneLoginAllowed 开发环境 或 显式开启 H5 手机号登录 时允许(按库内已绑定手机匹配用户,与小程序微信登录为同一套 users 表)
|
||||
func miniprogramPhoneLoginAllowed() bool {
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) == "development" {
|
||||
return true
|
||||
}
|
||||
// 本地常未设 APP_ENV;Gin 默认 GIN_MODE=debug,与 release 生产区分,避免联调 H5 误 403
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("GIN_MODE"))) == "debug" {
|
||||
return true
|
||||
}
|
||||
v := strings.TrimSpace(os.Getenv("H5_PHONE_LOGIN_ENABLED"))
|
||||
if v == "" {
|
||||
return false
|
||||
@@ -289,10 +312,14 @@ func miniprogramPhoneLoginAllowed() bool {
|
||||
}
|
||||
|
||||
// MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 与 POST /api/miniprogram/h5/login-by-phone
|
||||
// 使用手机号在库中查找已注册用户;开发环境默认可用;生产环境需设置 H5_PHONE_LOGIN_ENABLED=1
|
||||
// 使用手机号在库中查找已注册用户;须至少 6 位密码:password_hash 为空时写入 bcrypt,否则校验。
|
||||
// 放行条件:APP_ENV=development,或 GIN_MODE=debug(本地默认),或 H5_PHONE_LOGIN_ENABLED=1;生产须 release + 显式开关。
|
||||
func MiniprogramDevLoginByPhone(c *gin.Context) {
|
||||
if !miniprogramPhoneLoginAllowed() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "手机号登录未开启(开发环境可自动使用;生产需设置 H5_PHONE_LOGIN_ENABLED=1)"})
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "H5 手机号登录未开启:本机 soul-api 请使用 APP_ENV=development 或 GIN_MODE=debug;reactH5 请将 Vite 代理指向本机(默认 127.0.0.1:9100,见 vite.config)。若必须连线上 API,请在服务端设置 H5_PHONE_LOGIN_ENABLED=1",
|
||||
})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -315,6 +342,39 @@ func MiniprogramDevLoginByPhone(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"})
|
||||
return
|
||||
}
|
||||
|
||||
plain := strings.TrimSpace(req.Password)
|
||||
storedHash := ""
|
||||
if user.PasswordHash != nil {
|
||||
storedHash = strings.TrimSpace(*user.PasswordHash)
|
||||
}
|
||||
if storedHash == "" {
|
||||
if len(plain) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "首次登录请设置至少6位密码"})
|
||||
return
|
||||
}
|
||||
hp, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||||
return
|
||||
}
|
||||
hs := string(hp)
|
||||
if err := saveH5UserPasswordHash(db, user.ID, hs); err != nil {
|
||||
log.Printf("login-by-phone: save password_hash user=%s err=%v", user.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存密码失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if plain == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入密码"})
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "手机号或密码错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
openID := getStringValue(user.OpenID)
|
||||
if openID == "" {
|
||||
openID = user.ID
|
||||
@@ -371,6 +431,209 @@ func MiniprogramDevLoginByPhone(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
var h5PhoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
|
||||
// MiniprogramH5RegisterByPhone POST /api/miniprogram/h5/register-by-phone
|
||||
// 与 H5 登录同开关:仅允许未占用手机号的纯 H5 账号(无微信 open_id);注册成功后返回与 login-by-phone 一致的 data。
|
||||
func MiniprogramH5RegisterByPhone(c *gin.Context) {
|
||||
if !miniprogramPhoneLoginAllowed() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "H5 手机号注册未开启:与本机 h5/login-by-phone 403 说明相同,请开启 development / GIN_MODE=debug / H5_PHONE_LOGIN_ENABLED",
|
||||
})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
|
||||
if !h5PhoneRegexp.MatchString(phone) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入有效的大陆11位手机号"})
|
||||
return
|
||||
}
|
||||
pwd := strings.TrimSpace(req.Password)
|
||||
if len(pwd) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少6位"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var existed model.User
|
||||
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).
|
||||
First(&existed).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "该手机号已注册,请直接登录"})
|
||||
return
|
||||
}
|
||||
hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||||
return
|
||||
}
|
||||
hs := string(hp)
|
||||
|
||||
nickname := fmt.Sprintf("用户%s", phone[len(phone)-4:])
|
||||
avatar := ""
|
||||
hasFullBook := false
|
||||
earnings := 0.0
|
||||
pendingEarnings := 0.0
|
||||
referralCount := 0
|
||||
purchasedJSON := "[]"
|
||||
|
||||
var createdUser model.User
|
||||
created := false
|
||||
for attempt := 0; attempt < 12; attempt++ {
|
||||
userID := "user_" + randomSuffix()
|
||||
refBase := strings.ToUpper(randomSuffix())
|
||||
if len(refBase) < 6 {
|
||||
refBase = refBase + strings.ToUpper(randomSuffix())
|
||||
}
|
||||
rc := "SOUL" + refBase
|
||||
rc = strings.TrimSpace(rc)
|
||||
if len(rc) > 20 {
|
||||
rc = rc[:20]
|
||||
}
|
||||
var dupRC model.User
|
||||
if db.Where("referral_code = ?", rc).First(&dupRC).Error == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ph := phone
|
||||
u := model.User{
|
||||
ID: userID,
|
||||
Phone: &ph,
|
||||
PasswordHash: &hs,
|
||||
Nickname: &nickname,
|
||||
Avatar: &avatar,
|
||||
ReferralCode: &rc,
|
||||
HasFullBook: &hasFullBook,
|
||||
PurchasedSections: &purchasedJSON,
|
||||
Earnings: &earnings,
|
||||
PendingEarnings: &pendingEarnings,
|
||||
ReferralCount: &referralCount,
|
||||
}
|
||||
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
continue
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
createdUser = u
|
||||
created = true
|
||||
break
|
||||
}
|
||||
if !created {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册繁忙,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
AdminShensheShouAutoTag(createdUser.ID, phone)
|
||||
|
||||
// 对齐 login-by-phone 的 token 与用户 payload
|
||||
openID := getStringValue(createdUser.OpenID)
|
||||
if openID == "" {
|
||||
openID = createdUser.ID
|
||||
}
|
||||
tokenSuffix := openID
|
||||
if len(openID) >= 8 {
|
||||
tokenSuffix = openID[len(openID)-8:]
|
||||
}
|
||||
token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
|
||||
|
||||
var purchasedSections []string
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, createdUser.ID).Scan(&orderRows)
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
if purchasedSections == nil {
|
||||
purchasedSections = []string{}
|
||||
}
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": createdUser.ID,
|
||||
"openId": openID,
|
||||
"nickname": nickname,
|
||||
"avatar": resolveAvatarURLWithRequest(c, avatar),
|
||||
"phone": phone,
|
||||
"wechatId": "",
|
||||
"referralCode": getStringValue(createdUser.ReferralCode),
|
||||
"hasFullBook": false,
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": 0.0,
|
||||
"pendingEarnings": 0.0,
|
||||
"referralCount": 0,
|
||||
"createdAt": createdUser.CreatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramH5ResetPassword POST /api/miniprogram/h5/reset-password
|
||||
// 与 H5 手机号登录同开关;无短信/邮箱验码,依赖网关限流与 H5_PHONE_LOGIN_ENABLED 管控。
|
||||
func MiniprogramH5ResetPassword(c *gin.Context) {
|
||||
if !miniprogramPhoneLoginAllowed() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "H5 密码重置与登录共用开关,请见 h5/login-by-phone 403 说明或设置 H5_PHONE_LOGIN_ENABLED=1",
|
||||
})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
|
||||
pwd := strings.TrimSpace(req.NewPassword)
|
||||
if phone == "" || len(pwd) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入手机号及至少6位新密码"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err == nil {
|
||||
hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||||
return
|
||||
}
|
||||
hashStr := string(hp)
|
||||
if err := saveH5UserPasswordHash(db, user.ID, hashStr); err != nil {
|
||||
log.Printf("MiniprogramH5ResetPassword: update password_hash user=%s err=%v", user.ID, err)
|
||||
h := gin.H{"success": false, "error": "更新失败"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
h["detail"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, h)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "若该手机号已注册,已更新登录密码,请使用新密码登录",
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
|
||||
@@ -15,6 +15,8 @@ type User struct {
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
// H5/开发登录:bcrypt 哈希;微信登录用户可为空,首次 H5 登录时写入
|
||||
PasswordHash *string `gorm:"column:password_hash;size:128" json:"-"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
// P3 资料扩展(stitch_soul)
|
||||
|
||||
@@ -357,7 +357,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
|
||||
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 兼容旧路径:手机号登录
|
||||
miniprogram.POST("/h5/login-by-phone", handler.MiniprogramDevLoginByPhone) // H5 手机号登录(与 dev 同 handler)
|
||||
miniprogram.POST("/h5/login-by-phone", handler.MiniprogramDevLoginByPhone) // H5 手机号登录
|
||||
miniprogram.POST("/h5/register-by-phone", handler.MiniprogramH5RegisterByPhone)
|
||||
miniprogram.POST("/h5/reset-password", handler.MiniprogramH5ResetPassword) // H5 忘记密码(同登录开关)
|
||||
miniprogram.POST("/phone", handler.MiniprogramPhone)
|
||||
miniprogram.GET("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay", handler.MiniprogramPay)
|
||||
|
||||
Reference in New Issue
Block a user