feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本

- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
This commit is contained in:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -18,6 +18,7 @@ const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
@@ -120,10 +121,64 @@ Page({
// 好友从代付分享进入:待自动领取的 requestSn
pendingGiftRequestSn: '',
// 朋友圈单页模式scene 1154 / systemInfo.mode无法登录与支付仅引导「前往小程序」
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
},
/**
* 是否处于朋友圈等「单页预览」环境。
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
* 命中时同步 app.globalData.isSinglePageMode保证 ensureFullAppForAuth 与页内 wx:if 一致。
*/
_detectReadSinglePage() {
try {
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const enter = typeof wx.getEnterOptionsSync === 'function' ? wx.getEnterOptionsSync() : null
if (enter && Number(enter.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const win = typeof wx.getWindowInfo === 'function' ? wx.getWindowInfo() : null
if (win && win.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
const sp = this._detectReadSinglePage()
this.setData({
auditMode: app.globalData.auditMode || false,
readSinglePageMode: sp,
...(sp ? {} : { momentsPaywallExpanded: false }),
})
},
async onLoad(options) {
@@ -186,7 +241,9 @@ Page({
sectionMid: mid || null,
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || ''
pendingGiftRequestSn: giftRequestSn || '',
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -234,9 +291,10 @@ Page({
}
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
} else {
app.touchRecentSection(id)
}
// 5. 导航:文章详情已带 prev/next
@@ -375,6 +433,7 @@ Page({
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
app.touchRecentSection(id)
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
@@ -393,6 +452,7 @@ Page({
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
app.touchRecentSection(id)
console.log('[Read] 从本地缓存加载成功')
return
}
@@ -698,8 +758,8 @@ Page({
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请填写手机号(必填),便对方联系您',
title: '补全手机号',
content: '请填写手机号(必填),便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
@@ -784,11 +844,7 @@ Page({
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '朋友圈单页',
content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。',
showCancel: false
})
this.onUnlockTapInSinglePage()
return
}
} catch (e) {}
@@ -932,17 +988,9 @@ Page({
return { title, path }
},
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户
onShareTimelineTap() {
wx.showToast({
title: '请点击右上角「...」→ 分享到朋友圈',
icon: 'none',
duration: 2500
})
},
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
// 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
@@ -961,7 +1009,7 @@ Page({
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -988,21 +1036,10 @@ Page({
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '请前往完整小程序',
content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。',
showCancel: false,
confirmText: '我知道了',
})
return
}
} catch (e) {
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
// 单页模式无法弹登录组件:页内已说明「前往小程序」,不再弹 Modal
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
try {
this.setData({ showLoginModal: true })
@@ -1086,6 +1123,10 @@ Page({
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
console.log('[Pay] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1105,6 +1146,10 @@ Page({
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1123,6 +1168,14 @@ Page({
// 处理支付 - 调用真实微信支付接口
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
try {
wx.hideLoading()
} catch (e) {}
this.onUnlockTapInSinglePage()
return
}
const userInfo = app.globalData.userInfo
if (userInfo?.id) {
@@ -1132,10 +1185,10 @@ Page({
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像昵称',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '支付订单会关联你的对外展示信息,请先设置头像昵称,避免账单与对方看到默认占位。',
confirmText: '去设置',
cancelText: '关闭',
success: resolve
})
})
@@ -1294,7 +1347,7 @@ Page({
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
@@ -1414,6 +1467,7 @@ Page({
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
checkAndExecute('after_pay', this)
} catch (e) {
wx.hideLoading()
@@ -1499,6 +1553,25 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
/** 海报 canvas 在弹层渲染后偶现取不到 node多次重试 */
async _queryPosterCanvasNode(maxTry = 10, delayMs = 100) {
for (let i = 0; i < maxTry; i++) {
const node = await new Promise((resolve) => {
wx.createSelectorQuery()
.in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (res && res[0] && res[0].node) resolve(res[0])
else resolve(null)
})
})
if (node) return node
await new Promise((r) => setTimeout(r, delayMs))
}
return null
},
// 生成海报Canvas 2D API
async generatePoster() {
wx.showLoading({ title: '生成中...' })
@@ -1509,6 +1582,7 @@ Page({
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
else setTimeout(resolve, 50)
})
await new Promise((r) => setTimeout(r, 120))
try {
const { section, contentParagraphs, sectionId, sectionMid } = this.data
@@ -1526,18 +1600,12 @@ Page({
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
} catch (_) {}
const canvasNode = await new Promise((resolve, reject) => {
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (res && res[0] && res[0].node) resolve(res[0])
else reject(new Error('canvas node not found'))
})
})
const canvasNode = await this._queryPosterCanvasNode()
if (!canvasNode) {
throw new Error('canvas node not found')
}
const canvas = canvasNode.node
const ctx = canvas.getContext('2d')
let dpr = 2
try {
if (typeof wx.getWindowInfo === 'function') {
@@ -1548,73 +1616,100 @@ Page({
} catch (_) {
dpr = 2
}
const width = 300
const height = 450
canvas.width = width * dpr
canvas.height = height * dpr
// 布局尺寸:优先用节点测量;为 0 时回退 300×450避免真机 query 过早得到 0 导致空白)
const layoutW = (canvasNode.width && canvasNode.width > 1) ? Math.round(canvasNode.width) : 300
const layoutH = (canvasNode.height && canvasNode.height > 1) ? Math.round(canvasNode.height) : 450
canvas.width = Math.max(1, Math.floor(layoutW * dpr))
canvas.height = Math.max(1, Math.floor(layoutH * dpr))
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d not supported')
ctx.scale(dpr, dpr)
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, width, height)
const paintPoster = async () => {
const w = layoutW
const h = layoutH
const grd = ctx.createLinearGradient(0, 0, 0, h)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, width, 4)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, w, 4)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('📚 卡若创业派对', 20, 35)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('卡若创业派对', 20, 35)
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, width - 40)
let y = 70
titleLines.forEach(line => { ctx.fillText(line, 20, y); y += 26 })
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, w - 40)
let y = 70
titleLines.forEach((line) => { ctx.fillText(line, 20, y); y += 26 })
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(w - 20, y + 10)
ctx.stroke()
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText2d(ctx, summary, width - 40)
summaryLines.slice(0, 6).forEach(line => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, height - 100, width, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, width - 85, height - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, width, height)
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
let paras = Array.isArray(contentParagraphs) ? contentParagraphs.filter(Boolean) : []
if (!paras.length && this.data.content) {
const plain = String(this.data.content)
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (plain) paras = [plain.slice(0, 400)]
}
const rawSummary = paras.slice(0, 3).join(' ').trim() || '卡若创业派对 · 真实商业故事'
const summary = rawSummary.length > 160 ? rawSummary.slice(0, 157) + '...' : rawSummary
const summaryLines = this.wrapText2d(ctx, summary, w - 40)
summaryLines.slice(0, 6).forEach((line) => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, h - 100, w, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, h - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, h - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, w - 85, h - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, w, h)
}
} else {
this.drawQRPlaceholder2d(ctx, w, h)
}
}
if (typeof canvas.requestAnimationFrame === 'function') {
await new Promise((resolve, reject) => {
canvas.requestAnimationFrame(() => {
paintPoster().then(resolve).catch(reject)
})
})
} else {
this.drawQRPlaceholder2d(ctx, width, height)
await paintPoster()
}
wx.hideLoading()

View File

@@ -89,21 +89,21 @@
</view>
</view>
<!-- 分享操作区 -->
<!-- 分享区:仅好友/海报/代付;完整小程序发圈见右下角悬浮钮 -->
<view class="action-section">
<view class="action-row-inline">
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
<icon name="megaphone" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享到朋友圈</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<button plain class="action-share-native action-tile-unified" open-type="share" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="share" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享给好友</text>
</button>
<button plain class="action-share-native action-tile-unified" bindtap="generatePoster" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
</button>
<button plain class="action-share-native action-tile-unified" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">代付分享</text>
</view>
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
@@ -122,23 +122,36 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
<view class="paywall">
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并支付 ¥1 后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
</block>
</view>
<!-- 章节导航 -->
@@ -182,39 +195,47 @@
<view class="fade-mask"></view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall">
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<!-- 购买选项(审核模式隐藏) -->
<view class="purchase-options" wx:if="{{!auditMode}}">
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<!-- 解锁全书 - 只有购买超过3章才显示 -->
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice || 9.9}}</text>
<text class="btn-discount">省82%</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
</block>
</view>
<!-- 章节导航 -->
@@ -270,9 +291,9 @@
</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">
<!-- 海报生成弹窗:居中 + z-index 高于右下角悬浮,避免「空白」错觉 -->
<view class="modal-overlay modal-overlay-center" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content modal-content-center poster-modal" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">生成海报</text>
<view class="modal-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
@@ -357,8 +378,12 @@
</view>
</view>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
<!-- 单页预览(朋友圈):指向底栏「前往小程序」,字少;完整小程序仍保留发圈悬浮钮 -->
<view class="singlepage-launch-pointer" wx:if="{{readSinglePageMode && momentsPaywallExpanded}}" aria-hidden="true">
<view class="singlepage-launch-pointer__arrow"></view>
</view>
<view class="fab-share-moments" wx:if="{{!readSinglePageMode}}" bindtap="shareToMoments" hover-class="fab-share-moments-hover" aria-label="分享到朋友圈">
<icon name="share" size="44" color="#ffffff" customClass="fab-share-moments-icon"></icon>
</view>
</view>

View File

@@ -280,6 +280,36 @@
margin-bottom: 16rpx;
}
/* 朋友圈单页:与完整小程序同款购买行,留白略紧 */
.paywall--single-preview {
padding-top: 40rpx;
padding-bottom: 40rpx;
}
.paywall--single-preview .paywall-icon {
margin-bottom: 24rpx;
}
.paywall--single-preview .paywall-title {
margin-bottom: 28rpx;
}
.paywall-desc--moments-expanded {
margin-top: 28rpx !important;
margin-bottom: 0 !important;
font-size: 26rpx !important;
line-height: 1.45;
padding: 0 8rpx;
}
/* 朋友圈单页:未点解锁前的一行轻提示 */
.paywall-hint-compact {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.48);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.55;
padding: 0 16rpx;
}
.paywall-desc {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
@@ -360,6 +390,33 @@
margin-left: 8rpx;
}
.paywall-singlepage-note {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
text-align: center;
line-height: 1.5;
}
/* 朋友圈单页付费墙底部:与正文文末「分享赚收益」文案一致 */
.paywall-share-earn-wrap {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.paywall-share-earn-wrap .share-tip-text {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.paywall-share-earn-sub {
margin-top: 12rpx !important;
display: block;
}
.paywall-tip {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
@@ -470,7 +527,14 @@
.action-row-inline {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
align-items: stretch;
gap: 12rpx;
}
/* 底部三按钮:同一底纹与描边(好友 / 海报 / 代付) */
.action-tile-unified {
background: rgba(255, 255, 255, 0.06) !important;
border: 2rpx solid rgba(0, 206, 209, 0.28) !important;
}
.action-btn-inline {
@@ -489,21 +553,38 @@
overflow: hidden;
}
.action-btn-inline::after {
/* 分享给好友:原生 button + open-type=share样式与 action-btn-inline 对齐 */
.action-share-native {
flex: 1 1 0;
min-width: 0;
min-height: 96rpx;
margin: 0;
padding: 24rpx 12rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
line-height: normal;
font-size: inherit;
box-sizing: border-box;
overflow: hidden;
}
.action-share-native::after {
border: none;
}
.btn-share-inline {
background: rgba(7, 193, 96, 0.15);
border: 2rpx solid rgba(7, 193, 96, 0.3);
button.action-share-native {
color: inherit;
}
.action-share-native-hover {
opacity: 0.85;
}
.btn-poster-inline {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
.action-btn-inline::after {
border: none;
}
.action-icon-small {
font-size: 28rpx;
flex-shrink: 0;
@@ -597,7 +678,8 @@
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
/* 高于右下角悬浮钮,避免弹层被盖住或 canvas 不可见 */
z-index: 10050;
}
.modal-overlay-center {
@@ -1201,6 +1283,9 @@
/* ===== 海报弹窗 ===== */
.poster-modal {
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
max-height: 85vh;
overflow-y: auto;
box-sizing: border-box;
}
.poster-preview {
@@ -1251,44 +1336,54 @@
display: block;
}
/* ===== 右下角悬浮分享按钮 ===== */
.fab-share {
/* ===== 右下角:分享到朋友圈(固定悬浮小圆钮,不在文末分享行) ===== */
.fab-share-moments {
position: fixed;
right: 32rpx;
width:70rpx!important;
bottom: calc(120rpx + env(safe-area-inset-bottom));
height: 70rpx;
border-radius: 60rpx;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%);
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.4);
padding: 0;
margin: 0;
border: none;
z-index: 9999;
display:flex;
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
z-index: 9980;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.fab-share::after {
border: none;
.fab-share-moments-hover {
opacity: 0.9;
}
.fab-share:active {
transform: scale(0.95);
box-shadow: 0 4rpx 20rpx rgba(0, 206, 209, 0.5);
.fab-share-moments:active {
transform: scale(0.94);
}
.fab-icon {
padding:16rpx;
width: 50rpx;
height: 50rpx;
display: block;
}
.fab-share-icon {
.fab-share-moments-icon {
font-size: 44rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
}
/* 朋友圈单页:点「购买本章」后,箭头指向底栏「前往小程序」(字少,仅符号) */
.singlepage-launch-pointer {
position: fixed;
right: 48rpx;
bottom: calc(168rpx + env(safe-area-inset-bottom));
z-index: 99985;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.singlepage-launch-pointer__arrow {
font-size: 56rpx;
line-height: 1;
color: #00CED1;
text-shadow: 0 0 20rpx rgba(0, 206, 209, 0.55);
transform: rotate(0deg);
animation: singlepage-launch-pulse 1.25s ease-in-out infinite;
}
@keyframes singlepage-launch-pulse {
0%, 100% { opacity: 0.75; transform: translate(0, 0) scale(1); }
50% { opacity: 1; transform: translate(8rpx, 10rpx) scale(1.06); }
}