Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/app.js   resolved by devlop version
#	miniprogram/pages/chapters/chapters.js   resolved by devlop version
#	miniprogram/pages/match/match.js   resolved by devlop version
#	miniprogram/pages/member-detail/member-detail.js   resolved by devlop version
#	miniprogram/pages/my/my.js   resolved by devlop version
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/referral/referral.js   resolved by devlop version
#	soul-api/internal/model/person.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

View File

@@ -17,8 +17,8 @@ const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
@@ -93,7 +93,7 @@ Page({
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 0, // 来自 app.getTotalSections() 或 book/parts
totalSections: 62,
// 弹窗
showShareModal: false,
@@ -116,21 +116,69 @@ Page({
// 余额(用于余额支付)
walletBalance: 0,
// 未解锁时显示的预览比例来自文章详情用于付费墙「已阅读X%」)
previewPercent: 20,
// 审核模式:隐藏购买按钮
auditMode: false,
// 分润比例(来自 config.shareRate用于分享提示文案
shareRate: 90,
// 好友从代付分享进入:待自动领取的 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) {
@@ -194,7 +242,8 @@ Page({
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || '',
totalSections: app.getTotalSections()
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -205,12 +254,9 @@ Page({
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate,
totalSections: app.getTotalSections()
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 统一:先拉章节数据,用 isFree/price===0 判断免费
@@ -245,9 +291,10 @@ Page({
}
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
} else {
app.touchRecentSection(id)
}
// 5. 导航:文章详情已带 prev/next
@@ -380,15 +427,13 @@ Page({
chapterTitle: res.chapterTitle || ''
}
if (res.mid) updates.sectionMid = res.mid
if (res.previewPercent != null && res.previewPercent >= 1 && res.previewPercent <= 100) {
updates.previewPercent = res.previewPercent
}
this.setData(updates)
// 写入本地缓存(存 displayContent供离线/重试降级使用)
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
app.touchRecentSection(id)
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
@@ -407,6 +452,7 @@ Page({
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
app.touchRecentSection(id)
console.log('[Read] 从本地缓存加载成功')
return
}
@@ -683,13 +729,71 @@ Page({
})
},
// 存客宝留资:统一 soulBridge.submitCkbLead与会员详情点头像同链路
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
async _doMentionAddFriend(targetUserId, targetNickname) {
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
source: 'article_mention'
})
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'article_mention'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
// 分享弹窗
@@ -740,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) {}
@@ -798,8 +898,24 @@ Page({
}
const payParams = payRes.data.payParams
const orderSn = payRes.data.orderSn
await soulBridge.requestWxJsapiPayment(payParams)
await soulBridge.syncOrderStatusQuery(app, orderSn)
await new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType || 'RSA',
paySign: payParams.paySign,
success: resolve,
fail: reject
})
})
// 3) 主动同步(与其他支付流程一致)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
} catch (e) {}
}
wx.showToast({ title: '支付成功', icon: 'success' })
this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false })
@@ -817,7 +933,8 @@ Page({
// 复制链接
copyLink() {
const referralCode = app.getMyReferralCode() || ''
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
wx.setClipboardData({
@@ -833,10 +950,9 @@ Page({
copyShareText() {
const { section } = this.data
const total = app.getTotalSections()
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
${total}个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
@@ -872,17 +988,9 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
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')
@@ -901,7 +1009,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -928,21 +1036,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
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 })
@@ -1026,6 +1123,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买章节 - 直接调起支付
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 })
@@ -1045,6 +1146,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1063,6 +1168,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 处理支付 - 调用真实微信支付接口
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) {
@@ -1072,10 +1185,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像昵称',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '支付订单会关联你的对外展示信息,请先设置头像昵称,避免账单与对方看到默认占位。',
confirmText: '去设置',
cancelText: '关闭',
success: resolve
})
})
@@ -1132,7 +1245,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
// 0. 尝试余额支付(若余额足够)
const userId = app.globalData.userInfo?.id
const referralCode = soulBridge.getReferralCodeForPay(app)
const referralCode = wx.getStorageSync('referral_code') || ''
if (userId) {
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
@@ -1196,9 +1309,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
let paymentData = null
try {
// 获取章节完整名称用于支付描述
const sectionTitle = this.data.section?.title || sectionId
const description = soulBridge.buildSectionPayDescription(type, sectionId, sectionTitle)
const referralCode = soulBridge.getReferralCodeForPay(app)
const description = type === 'fullbook'
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -1229,7 +1347,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
@@ -1250,11 +1368,18 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
await soulBridge.requestWxJsapiPayment(paymentData)
await this.callWechatPay(paymentData)
// 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题)
const orderSn = paymentData._orderSn || paymentData.orderSn
await soulBridge.syncOrderStatusQuery(app, orderSn)
if (orderSn) console.log('[Pay] 已主动同步订单状态:', orderSn)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
console.log('[Pay] 已主动同步订单状态:', orderSn)
} catch (e) {
console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e)
}
}
// 5. 【标准流程】刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
@@ -1342,6 +1467,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
checkAndExecute('after_pay', this)
} catch (e) {
wx.hideLoading()
@@ -1389,6 +1515,21 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
}
},
// 调用微信支付
callWechatPay(paymentData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: resolve,
fail: reject
})
})
},
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
@@ -1412,6 +1553,25 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
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: '生成中...' })
@@ -1422,6 +1582,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
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
@@ -1439,18 +1600,12 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
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') {
@@ -1461,73 +1616,100 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
} 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()
@@ -1635,13 +1817,11 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 重新拉取章节,用 isFree/price 判断免费
const chapterRes = await app.request({
url: this._getChapterUrl({}),

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">分享后好友购买,你可获得 {{shareRate || 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); }
}