feat: 内容管理第5批优化 - Bug修复 + 分享功能 + 代付功能

1. Bug修复:
   - 修复Markdown星号/下划线在小程序端原样显示问题(markdownToHtml增加__和_支持,contentParser增加Markdown格式剥离)
   - 修复@提及无反应(MentionSuggestion使用ref保持persons最新值,解决闭包捕获空数组问题)
   - 修复#链接标签点击"未找到小程序配置"(增加appId直接跳转降级路径)

2. 分享功能优化:
   - "分享到朋友圈"改为"分享给好友"(open-type从shareTimeline改为share)
   - 90%收益提示移到分享按钮下方
   - 阅读20%后向上滑动弹出分享浮层提示(4秒自动消失)

3. 代付功能:
   - 后端:新增UserBalance/BalanceTransaction/GiftUnlock三个模型
   - 后端:新增8个余额相关API(查询/充值/充值确认/代付/领取/退款/交易记录/礼物信息)
   - 小程序:阅读页新增"代付分享"按钮,支持用余额为好友解锁章节
   - 分享链接携带gift参数,好友打开自动领取解锁

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 09:20:27 +08:00
parent 8778a42429
commit 991e17698c
260 changed files with 26780 additions and 1026 deletions

View File

@@ -431,6 +431,7 @@ App({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',

View File

@@ -57,7 +57,10 @@
]
},
"usingComponents": {},
"navigateToMiniProgramAppIdList": [],
"navigateToMiniProgramAppIdList": [
"wx6489c26045912fe1",
"wx3d15ed02e98b04e3"
],
"__usePrivacyCheck__": true,
"lazyCodeLoading": "requiredComponents",
"style": "v2",

View File

@@ -108,13 +108,15 @@ Page({
this.updateUserStatus()
},
// 初始化数据:首次进页面并行异步加载,加快首屏展示
initData() {
this.setData({ loading: false })
this.loadBookData()
this.loadFeaturedFromServer()
this.loadSuperMembers()
this.loadLatestChapters()
Promise.all([
this.loadBookData(),
this.loadFeaturedFromServer(),
this.loadSuperMembers(),
this.loadLatestChapters()
]).finally(() => {
this.setData({ loading: false })
})
},
async loadSuperMembers() {

View File

@@ -6,6 +6,7 @@ Page({
title: '链接预览',
statusBarHeight: 44,
navBarHeight: 88,
loadError: false,
},
onLoad(options) {
@@ -19,6 +20,26 @@ Page({
})
},
onWebViewError() {
this.setData({ loadError: true })
wx.showModal({
title: '无法在小程序内打开',
content: '该链接无法在小程序内预览,是否复制链接到浏览器打开?',
confirmText: '复制链接',
cancelText: '返回',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: this.data.url,
success: () => wx.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none', duration: 2000 }),
})
} else {
this.goBack()
}
},
})
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {

View File

@@ -18,8 +18,14 @@
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
<!-- 链接预览区域 -->
<view class="webview-wrap" wx:if="{{url}}">
<web-view src="{{url}}"></web-view>
<view class="webview-wrap" wx:if="{{url && !loadError}}">
<web-view src="{{url}}" binderror="onWebViewError"></web-view>
</view>
<view class="error-wrap" wx:elif="{{loadError}}">
<text class="error-text">该链接无法在小程序内预览</text>
<view class="error-btn" bindtap="copyLink">
<text class="error-btn-text">复制链接到浏览器打开</text>
</view>
</view>
<view class="empty-wrap" wx:else>
<text class="empty-text">暂无链接地址</text>

View File

@@ -81,3 +81,27 @@ web-view {
font-size: 26rpx;
}
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx;
}
.error-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.error-btn {
padding: 16rpx 40rpx;
border-radius: 999rpx;
background: #38bdac;
}
.error-btn-text {
font-size: 26rpx;
color: #fff;
}

View File

@@ -70,6 +70,9 @@ Page({
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
showShareTip: false,
_shareTipShown: false,
_lastScrollTop: 0,
// 章节 mid扫码/海报分享用,便于分享 path 带 mid
sectionMid: null
@@ -97,8 +100,6 @@ Page({
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
console.log("页面:",mid);
// mid 有值但无 id 时,从 bookData 或 API 解析 id
if (mid && !id) {
const bookData = app.globalData.bookData || []
@@ -138,6 +139,11 @@ Page({
app.handleReferralCode({ query: { ref } })
}
const giftCode = options.gift || ''
if (giftCode) {
this._pendingGiftCode = giftCode
}
try {
const config = await accessManager.fetchLatestConfig()
this.setData({
@@ -160,6 +166,13 @@ Page({
// 加载内容(复用已拉取的章节数据,避免二次请求)
await this.loadContent(id, accessState, chapterRes)
// 自动领取礼物码(代付解锁)
if (this._pendingGiftCode && !canAccess && app.globalData.isLoggedIn) {
await this._redeemGiftCode(this._pendingGiftCode)
this._pendingGiftCode = null
return
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
@@ -184,6 +197,11 @@ Page({
return
}
const currentScrollTop = e.scrollTop || 0
const lastScrollTop = this.data._lastScrollTop || 0
const isScrollingDown = currentScrollTop < lastScrollTop
this.setData({ _lastScrollTop: currentScrollTop })
// 获取滚动信息并更新追踪器
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
@@ -202,6 +220,12 @@ Page({
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
: 0
this.setData({ readingProgress: progress })
// 阅读超过20%且向上滑动时,弹出一次分享提示
if (progress >= 20 && isScrollingDown && !this.data._shareTipShown) {
this.setData({ showShareTip: true, _shareTipShown: true })
setTimeout(() => { this.setData({ showShareTip: false }) }, 4000)
}
// 更新阅读追踪器(记录最大进度、判断是否读完)
readingTracker.updateProgress(scrollInfo)
@@ -492,33 +516,38 @@ Page({
}
}
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
// CKB 类型:走「链接卡若」留资流程(与首页 onLinkKaruo 一致)
if (tagType === 'ckb') {
// 触发通用加好友(无特定 personId使用全局 CKB Key
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
this._doCkbLead(label)
return
}
// 小程序类型:用密钥查 linkedMiniprograms 得 appId再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
// 小程序类型:查 linkedMiniprograms 得 appId降级直接用 mpKey/appId 字段
if (tagType === 'miniprogram') {
let appId = (e.currentTarget.dataset.appId || '').trim()
if (!mpKey && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) mpKey = cached.mpKey || ''
if (cached) {
mpKey = cached.mpKey || ''
if (!appId && cached.appId) appId = cached.appId
}
}
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
if (linked && linked.appId) {
const targetAppId = (linked && linked.appId) ? linked.appId : (appId || mpKey || '')
if (targetAppId) {
wx.navigateToMiniProgram({
appId: linked.appId,
path: pagePath || linked.path || '',
appId: targetAppId,
path: pagePath || (linked && linked.path) || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
console.warn('[LinkTag] 小程序跳转失败:', err)
wx.showToast({ title: '跳转失败,请检查小程序配置', icon: 'none' })
},
})
return
}
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
wx.showToast({ title: '未配置关联小程序', icon: 'none' })
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
@@ -638,6 +667,76 @@ Page({
}
},
async _doCkbLead(label) {
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 userId = app.globalData.userInfo.id
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
}
} catch (e) {}
}
if (!phone && !wechatId) {
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/index-lead',
method: 'POST',
data: {
userId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
source: 'article_ckb_tag',
tagLabel: label || undefined
}
})
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' })
}
},
// 分享弹窗
showShare() {
this.setData({ showShareModal: true })
@@ -687,14 +786,19 @@ Page({
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const shareTitle = section?.title
const giftCode = this._giftCodeToShare || ''
this._giftCodeToShare = null
let shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
return {
title: shareTitle,
path: ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
// 不设置 imageUrl使用当前阅读页截图作为分享卡片中间图片
}
if (giftCode) shareTitle = `🎁 好友已为你解锁:${section?.title || '精选文章'}`
let path = `/pages/read/read?${q}`
if (ref) path += `&ref=${ref}`
if (giftCode) path += `&gift=${giftCode}`
return { title: shareTitle, path }
},
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
@@ -1357,7 +1461,84 @@ Page({
closePosterModal() {
this.setData({ showPosterModal: false })
},
closeShareTip() {
this.setData({ showShareTip: false })
},
// 代付分享:用余额帮好友解锁当前章节
async handleGiftPay() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } })
return
}
const sectionId = this.data.sectionId
const userId = app.globalData.userInfo.id
const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1)
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
wx.showModal({
title: '代付分享',
content: `为好友代付本章 ¥${price}\n当前余额: ¥${balance.toFixed(2)}\n${balance < price ? '余额不足,请先充值' : '确认后将从余额扣除'}`,
confirmText: balance >= price ? '确认代付' : '去充值',
success: async (res) => {
if (!res.confirm) return
if (balance < price) {
wx.navigateTo({ url: '/pages/wallet/wallet' })
return
}
wx.showLoading({ title: '处理中...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
const giftCode = giftRes.data.giftCode
wx.showModal({
title: '代付成功!',
content: `已为好友代付 ¥${price},分享链接后好友可免费阅读`,
confirmText: '分享给好友',
success: (r) => {
if (r.confirm) {
this._giftCodeToShare = giftCode
wx.shareAppMessage()
}
}
})
} else {
wx.showToast({ title: (giftRes && giftRes.error) || '代付失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '代付失败', icon: 'none' })
}
}
})
},
// 领取礼物码解锁
async _redeemGiftCode(giftCode) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
try {
const res = await app.request({
url: '/api/miniprogram/balance/gift/redeem',
method: 'POST',
data: { giftCode, receiverId: app.globalData.userInfo.id }
})
if (res && res.data) {
wx.showToast({ title: '好友已为你解锁!', icon: 'success' })
this.onLoad({ id: this.data.sectionId })
}
} catch (e) {
console.warn('[Gift] 领取失败:', e)
}
},
// 保存海报到相册
savePoster() {
wx.canvasToTempFilePath({

View File

@@ -25,7 +25,7 @@
<!-- 阅读内容 -->
<view class="read-content">
<!-- 章节标题 -->
<view class="chapter-header">
<view class="chapter-header" wx:if="{{section}}">
<view class="chapter-meta">
<text class="chapter-id">{{section.id}}</text>
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
@@ -85,15 +85,22 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享到朋友圈</text>
<text class="action-text-small">分享给好友</text>
</button>
<view class="action-btn-inline btn-gift-inline" bindtap="handleGiftPay">
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>
</view>
</view>
<view class="share-tip-inline">
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
</view>
</view>
</view>
@@ -242,6 +249,14 @@
</view>
</view>
<!-- 分享提示浮层阅读20%后下拉触发) -->
<view class="share-float-tip {{showShareTip ? 'show' : ''}}" wx:if="{{showShareTip}}">
<text class="share-float-icon">💰</text>
<text class="share-float-text">分享给好友,好友购买你可获得 90% 收益</text>
<button class="share-float-btn" open-type="share">立即分享</button>
<view class="share-float-close" bindtap="closeShareTip">✕</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">

View File

@@ -1003,3 +1003,77 @@
display: block;
}
/* ===== 分享提示文字(底部导航上方) ===== */
.share-tip-inline {
text-align: center;
margin-top: 16rpx;
}
.share-tip-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 分享浮层提示阅读20%触发) ===== */
.share-float-tip {
position: fixed;
top: 180rpx;
left: 40rpx;
right: 40rpx;
background: linear-gradient(135deg, #1a3a4a 0%, #0d2533 100%);
border: 1rpx solid rgba(0, 206, 209, 0.3);
border-radius: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
z-index: 10000;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(-40rpx);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.share-float-tip.show {
opacity: 1;
transform: translateY(0);
}
.share-float-icon {
font-size: 40rpx;
flex-shrink: 0;
}
.share-float-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
flex: 1;
}
.share-float-btn {
background: linear-gradient(135deg, #00CED1, #20B2AA) !important;
color: #fff !important;
font-size: 24rpx;
padding: 10rpx 28rpx;
border-radius: 32rpx;
border: none;
flex-shrink: 0;
line-height: 1.5;
}
.share-float-btn::after {
border: none;
}
.share-float-close {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
padding: 8rpx;
flex-shrink: 0;
}
/* ===== 代付分享按钮 ===== */
.btn-gift-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 12rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 165, 0, 0.1);
border: 1rpx solid rgba(255, 165, 0, 0.3);
}

View File

@@ -27,8 +27,8 @@
"name": "唤醒",
"pathName": "pages/read/read",
"query": "mid=209",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/my/my",

View File

@@ -144,9 +144,29 @@ function parseHtmlToSegments(html) {
return { lines, segments }
}
/** 纯文本按行解析(无 HTML 标签) */
/** 清理 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
}
/** 纯文本/Markdown 按行解析 */
function parsePlainTextToSegments(text) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const cleaned = stripMarkdownFormatting(text)
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => [{ type: 'text', text: line }])
return { lines, segments }
}