Merge branch 'yongxu' into devlop
# Conflicts: # .cursor/meeting/README.md resolved by yongxu version # .gitignore resolved by yongxu version # miniprogram/pages/index/index.js resolved by yongxu version # miniprogram/pages/read/read.js resolved by yongxu version # miniprogram/pages/read/read.wxml resolved by yongxu version # soul-admin/dist/index.html resolved by yongxu version # soul-admin/src/App.tsx resolved by yongxu version # soul-admin/src/components/RichEditor.css resolved by yongxu version # soul-admin/src/components/RichEditor.tsx resolved by yongxu version # soul-admin/src/components/modules/user/UserDetailModal.tsx resolved by yongxu version # soul-admin/src/layouts/AdminLayout.tsx resolved by yongxu version # soul-admin/src/pages/chapters/ChaptersPage.tsx resolved by yongxu version # soul-admin/src/pages/content/ContentPage.tsx resolved by yongxu version # soul-admin/src/pages/dashboard/DashboardPage.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/FindPartnerPage.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/MentorTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx resolved by yongxu version # soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx resolved by yongxu version # soul-admin/src/pages/mentors/MentorsPage.tsx resolved by yongxu version # soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx resolved by yongxu version # soul-admin/src/pages/settings/SettingsPage.tsx resolved by yongxu version # soul-admin/src/pages/users/UsersPage.tsx resolved by yongxu version # soul-admin/tsconfig.tsbuildinfo resolved by yongxu version # soul-api/internal/database/database.go resolved by yongxu version # soul-api/internal/handler/admin_dashboard.go resolved by yongxu version # soul-api/internal/handler/book.go resolved by yongxu version # soul-api/internal/handler/ckb.go resolved by yongxu version # soul-api/internal/handler/db_book.go resolved by yongxu version # soul-api/internal/handler/db_person.go resolved by yongxu version # soul-api/internal/handler/match_records.go resolved by yongxu version # soul-api/internal/handler/user.go resolved by yongxu version # soul-api/internal/model/chapter.go resolved by yongxu version # soul-api/internal/model/person.go resolved by yongxu version # soul-api/internal/router/router.go resolved by yongxu version # 开发文档/10、项目管理/运营与变更.md resolved by yongxu version # 开发文档/1、需求/需求汇总.md resolved by yongxu version # 开发文档/README.md resolved by yongxu version
This commit is contained in:
@@ -2,42 +2,24 @@
|
||||
* Soul创业派对 - 阅读页(标准流程版)
|
||||
* 开发: 卡若
|
||||
* 技术支持: 存客宝
|
||||
*
|
||||
*
|
||||
* 更新: 2026-02-04
|
||||
* - 引入权限管理器(chapterAccessManager)统一权限判断
|
||||
* - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完
|
||||
* - 使用状态机(accessState)规范权限流转
|
||||
* - 异常统一保守处理,避免误解锁
|
||||
*
|
||||
* 更新: 正文 @某人({{@userId:昵称}})
|
||||
*
|
||||
* 更新: 正文 @某人(TipTap HTML <span data-type="mention">)
|
||||
* - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
|
||||
*/
|
||||
|
||||
import accessManager from '../../utils/chapterAccessManager'
|
||||
import readingTracker from '../../utils/readingTracker'
|
||||
const { parseScene } = require('../../utils/scene.js')
|
||||
const contentParser = require('../../utils/contentParser.js')
|
||||
|
||||
const app = getApp()
|
||||
|
||||
// 解析单行中的 {{@userId:昵称}} 为片段数组,用于阅读页 @ 高亮与点击
|
||||
function parseLineToSegments(line) {
|
||||
const segments = []
|
||||
const re = /\{\{@([^:]+):(.*?)\}\}/g
|
||||
let lastEnd = 0
|
||||
let m
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m.index > lastEnd) {
|
||||
segments.push({ type: 'text', text: line.slice(lastEnd, m.index) })
|
||||
}
|
||||
segments.push({ type: 'mention', userId: String(m[1]).trim(), nickname: String(m[2] || '').trim() })
|
||||
lastEnd = re.lastIndex
|
||||
}
|
||||
if (lastEnd < line.length) {
|
||||
segments.push({ type: 'text', text: line.slice(lastEnd) })
|
||||
}
|
||||
return segments.length ? segments : [{ type: 'text', text: line }]
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
// 系统信息
|
||||
@@ -54,7 +36,7 @@ Page({
|
||||
content: '',
|
||||
previewContent: '',
|
||||
contentParagraphs: [],
|
||||
contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}],支持 @ 高亮可点
|
||||
contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}]
|
||||
previewParagraphs: [],
|
||||
loading: true,
|
||||
|
||||
@@ -90,13 +72,21 @@ Page({
|
||||
isGeneratingPoster: false,
|
||||
|
||||
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
||||
sectionMid: null,
|
||||
// 朋友圈分享文案:章节正文前文,用于 onShareTimeline 的 title(不折叠时显示)
|
||||
shareTimelineTitle: ''
|
||||
sectionMid: null
|
||||
},
|
||||
|
||||
async onLoad(options) {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
|
||||
// 预加载 linkTags 配置(供 onLinkTagTap 旧格式降级匹配 type 用)
|
||||
if (!app.globalData.linkTagsConfig) {
|
||||
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
|
||||
if (cfg && Array.isArray(cfg.linkTags)) {
|
||||
app.globalData.linkTagsConfig = cfg.linkTags
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 支持 scene(扫码)、mid、id、ref
|
||||
const sceneStr = (options && options.scene) || ''
|
||||
const parsed = parseScene(sceneStr)
|
||||
@@ -116,7 +106,9 @@ Page({
|
||||
id = ch.id
|
||||
} else {
|
||||
try {
|
||||
const chRes = await app.request({ url: `/api/miniprogram/book/chapter/by-mid/${mid}`, silent: true })
|
||||
const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}`
|
||||
const uid = app.globalData.userInfo?.id
|
||||
const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true })
|
||||
if (chRes && chRes.id) id = chRes.id
|
||||
} catch (e) {
|
||||
console.warn('[Read] by-mid 解析失败:', e)
|
||||
@@ -216,9 +208,9 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
|
||||
// prefetchedChapter:若已有章节数据(含 content)则复用,避免二次请求
|
||||
// 加载章节内容:优先复用 prefetchedChapter 避免二次请求,失败时降级本地缓存
|
||||
async loadContent(id, accessState, prefetchedChapter) {
|
||||
const cacheKey = `chapter_${id}`
|
||||
try {
|
||||
const sectionPrice = this.data.sectionPrice ?? 1
|
||||
let res = prefetchedChapter
|
||||
@@ -232,48 +224,45 @@ Page({
|
||||
price: res.price ?? sectionPrice
|
||||
}
|
||||
this.setData({ section })
|
||||
|
||||
if (res && res.content) {
|
||||
const lines = res.content.split('\n').filter(line => line.trim())
|
||||
|
||||
// 已解锁用 data.content(完整内容),未解锁用 content(预览);先 determineAccessState 再 loadContent 保证顺序正确
|
||||
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
|
||||
if (res && displayContent) {
|
||||
const { lines, segments } = contentParser.parseContent(displayContent)
|
||||
const previewCount = Math.ceil(lines.length * 0.2)
|
||||
const rawText = (res.content || '').replace(/<[^>]+>/g, '').replace(/#[^\n]*/g, '').replace(/\s+/g, ' ').trim()
|
||||
const shareTimelineTitle = rawText.length > 50 ? rawText.slice(0, 50) + '...' : rawText
|
||||
const updates = {
|
||||
content: res.content,
|
||||
content: displayContent,
|
||||
contentParagraphs: lines,
|
||||
contentSegments: lines.map(parseLineToSegments),
|
||||
contentSegments: segments,
|
||||
previewParagraphs: lines.slice(0, previewCount),
|
||||
partTitle: res.partTitle || '',
|
||||
chapterTitle: res.chapterTitle || '',
|
||||
shareTimelineTitle: shareTimelineTitle || (section?.title || '') || 'Soul创业派对 - 真实商业故事'
|
||||
chapterTitle: res.chapterTitle || ''
|
||||
}
|
||||
if (res.mid) updates.sectionMid = res.mid
|
||||
this.setData(updates)
|
||||
|
||||
// 如果有权限,标记为已读
|
||||
// 写入本地缓存(存 displayContent,供离线/重试降级使用)
|
||||
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
|
||||
if (accessManager.canAccessFullContent(accessState)) {
|
||||
app.markSectionAsRead(id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Read] 加载内容失败:', e)
|
||||
// 尝试从本地缓存加载
|
||||
const cacheKey = `chapter_${id}`
|
||||
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
|
||||
try {
|
||||
const cached = wx.getStorageSync(cacheKey)
|
||||
if (cached && cached.content) {
|
||||
const lines = cached.content.split('\n').filter(line => line.trim())
|
||||
const { lines, segments } = contentParser.parseContent(cached.content)
|
||||
const previewCount = Math.ceil(lines.length * 0.2)
|
||||
const rawText = (cached.content || '').replace(/<[^>]+>/g, '').replace(/#[^\n]*/g, '').replace(/\s+/g, ' ').trim()
|
||||
const shareTimelineTitle = rawText.length > 50 ? rawText.slice(0, 50) + '...' : rawText
|
||||
this.setData({
|
||||
content: cached.content,
|
||||
contentParagraphs: lines,
|
||||
contentSegments: lines.map(parseLineToSegments),
|
||||
contentSegments: segments,
|
||||
previewParagraphs: lines.slice(0, previewCount),
|
||||
shareTimelineTitle: shareTimelineTitle || 'Soul创业派对 - 真实商业故事'
|
||||
partTitle: cached.partTitle || '',
|
||||
chapterTitle: cached.chapterTitle || ''
|
||||
})
|
||||
console.log('[Read] 从本地缓存加载成功')
|
||||
return
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.warn('[Read] 本地缓存也失败:', cacheErr)
|
||||
@@ -329,59 +318,22 @@ Page({
|
||||
return titles[id] || `章节 ${id}`
|
||||
},
|
||||
|
||||
// 根据 id/mid 构造章节接口路径(优先使用 mid)
|
||||
// 根据 id/mid 构造章节接口路径(优先使用 mid)。必须带 userId 才能让后端正确判断付费用户并返回完整内容
|
||||
_getChapterUrl(params = {}) {
|
||||
const { id, mid } = params
|
||||
const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid
|
||||
let url
|
||||
if (finalMid) {
|
||||
return `/api/miniprogram/book/chapter/by-mid/${finalMid}`
|
||||
url = `/api/miniprogram/book/chapter/by-mid/${finalMid}`
|
||||
} else {
|
||||
const finalId = id || this.data.sectionId
|
||||
url = `/api/miniprogram/book/chapter/${finalId}`
|
||||
}
|
||||
const finalId = id || this.data.sectionId
|
||||
return `/api/miniprogram/book/chapter/${finalId}`
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId)
|
||||
return url
|
||||
},
|
||||
|
||||
// 加载内容 - 三级降级方案:API → 本地缓存 → 备用API
|
||||
async loadContent(id) {
|
||||
const cacheKey = `chapter_${id}`
|
||||
|
||||
// 1. 优先从API获取
|
||||
try {
|
||||
const res = await this.fetchChapterWithTimeout(id, 5000)
|
||||
if (res && res.content) {
|
||||
this.setChapterContent(res)
|
||||
// 成功后缓存到本地
|
||||
wx.setStorageSync(cacheKey, res)
|
||||
console.log('[Read] 从API加载成功:', id)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Read] API加载失败,尝试本地缓存:', e.message)
|
||||
}
|
||||
|
||||
// 2. API失败,尝试从本地缓存读取
|
||||
try {
|
||||
const cached = wx.getStorageSync(cacheKey)
|
||||
if (cached && cached.content) {
|
||||
this.setChapterContent(cached)
|
||||
console.log('[Read] 从本地缓存加载成功:', id)
|
||||
// 后台静默刷新
|
||||
this.silentRefresh(id)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Read] 本地缓存读取失败')
|
||||
}
|
||||
|
||||
// 3. 都失败,显示加载中并持续重试
|
||||
this.setData({
|
||||
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
|
||||
contentSegments: [parseLineToSegments('章节内容加载中...'), parseLineToSegments('正在尝试连接服务器,请稍候...')],
|
||||
previewParagraphs: ['章节内容加载中...']
|
||||
})
|
||||
|
||||
// 延迟重试(最多3次)
|
||||
this.retryLoadContent(id, 3)
|
||||
},
|
||||
|
||||
// 带超时的章节请求
|
||||
fetchChapterWithTimeout(id, timeout = 5000) {
|
||||
@@ -402,14 +354,13 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 设置章节内容
|
||||
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML)
|
||||
setChapterContent(res) {
|
||||
const lines = res.content.split('\n').filter(line => line.trim())
|
||||
const { lines, segments } = contentParser.parseContent(res.content)
|
||||
const previewCount = Math.ceil(lines.length * 0.2)
|
||||
const sectionPrice = this.data.sectionPrice ?? 1
|
||||
const sectionTitle = (res.sectionTitle || res.title || '').trim()
|
||||
const rawText = (res.content || '').replace(/<[^>]+>/g, '').replace(/#[^\n]*/g, '').replace(/\s+/g, ' ').trim()
|
||||
const shareTimelineTitle = rawText.length > 50 ? rawText.slice(0, 50) + '...' : (rawText || sectionTitle)
|
||||
|
||||
this.setData({
|
||||
// 文章详情标题:只使用后端提供的 sectionTitle,不再拼接其他本地标题信息
|
||||
section: {
|
||||
@@ -421,11 +372,11 @@ Page({
|
||||
content: res.content,
|
||||
previewContent: lines.slice(0, previewCount).join('\n'),
|
||||
contentParagraphs: lines,
|
||||
contentSegments: lines.map(parseLineToSegments),
|
||||
contentSegments: segments,
|
||||
previewParagraphs: lines.slice(0, previewCount),
|
||||
partTitle: res.partTitle || '',
|
||||
chapterTitle: sectionTitle,
|
||||
shareTimelineTitle: shareTimelineTitle || 'Soul创业派对 - 真实商业故事'
|
||||
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
|
||||
chapterTitle: sectionTitle
|
||||
})
|
||||
},
|
||||
|
||||
@@ -447,7 +398,7 @@ Page({
|
||||
if (currentRetry >= maxRetries) {
|
||||
this.setData({
|
||||
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
|
||||
contentSegments: [parseLineToSegments('内容加载失败'), parseLineToSegments('请检查网络连接后下拉刷新重试')],
|
||||
contentSegments: contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments,
|
||||
previewParagraphs: ['内容加载失败']
|
||||
})
|
||||
return
|
||||
@@ -470,54 +421,31 @@ Page({
|
||||
},
|
||||
|
||||
|
||||
// 加载导航:以后台章节真实 sort_order 为准
|
||||
async loadNavigation(id) {
|
||||
try {
|
||||
let rows = app.globalData.bookData || []
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
rows = (res && (res.data || res.chapters)) || []
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
app.globalData.bookData = rows
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
return
|
||||
}
|
||||
|
||||
const orderedSections = rows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const sortA = a.sortOrder ?? a.sort_order ?? 999999
|
||||
const sortB = b.sortOrder ?? b.sort_order ?? 999999
|
||||
if (sortA !== sortB) return sortA - sortB
|
||||
const midA = a.mid ?? a.MID ?? 0
|
||||
const midB = b.mid ?? b.MID ?? 0
|
||||
if (midA !== midB) return midA - midB
|
||||
return String(a.id || '').localeCompare(String(b.id || ''))
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
mid: item.mid ?? item.MID ?? 0,
|
||||
title: item.sectionTitle || item.section_title || item.title || item.chapterTitle || this.getSectionTitle(item.id)
|
||||
}))
|
||||
|
||||
const currentIndex = orderedSections.findIndex((item) => item.id === id)
|
||||
if (currentIndex === -1) {
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
return
|
||||
}
|
||||
|
||||
const prevSection = currentIndex > 0 ? orderedSections[currentIndex - 1] : null
|
||||
const nextSection = currentIndex < orderedSections.length - 1 ? orderedSections[currentIndex + 1] : null
|
||||
|
||||
this.setData({ prevSection, nextSection })
|
||||
} catch (e) {
|
||||
console.error('[Read] 加载上下篇导航失败:', e)
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
}
|
||||
// 加载导航
|
||||
loadNavigation(id) {
|
||||
const sectionOrder = [
|
||||
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
|
||||
'2.1', '2.2', '2.3', '2.4', '2.5',
|
||||
'3.1', '3.2', '3.3', '3.4',
|
||||
'4.1', '4.2', '4.3', '4.4', '4.5',
|
||||
'5.1', '5.2', '5.3', '5.4', '5.5',
|
||||
'6.1', '6.2', '6.3', '6.4',
|
||||
'7.1', '7.2', '7.3', '7.4', '7.5',
|
||||
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
|
||||
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
|
||||
'10.1', '10.2', '10.3', '10.4',
|
||||
'11.1', '11.2', '11.3', '11.4', '11.5',
|
||||
'epilogue'
|
||||
]
|
||||
|
||||
const currentIndex = sectionOrder.indexOf(id)
|
||||
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
|
||||
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
|
||||
|
||||
this.setData({
|
||||
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
|
||||
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
|
||||
})
|
||||
},
|
||||
|
||||
// 返回(从分享进入无栈时回首页)
|
||||
@@ -525,6 +453,69 @@ Page({
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
// 点击正文中的 #链接标签:外链复制到剪贴板,小程序内页直接跳转
|
||||
onLinkTagTap(e) {
|
||||
let url = (e.currentTarget.dataset.url || '').trim()
|
||||
const label = (e.currentTarget.dataset.label || '').trim()
|
||||
let tagType = (e.currentTarget.dataset.tagType || '').trim()
|
||||
let pagePath = (e.currentTarget.dataset.pagePath || '').trim()
|
||||
|
||||
// 旧格式(<a href>)tagType 为空 → 按 label 从缓存 linkTags 补充类型信息
|
||||
if (!tagType && label) {
|
||||
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
|
||||
if (cached) {
|
||||
tagType = cached.type || 'url'
|
||||
pagePath = cached.pagePath || ''
|
||||
if (!url) url = cached.url || ''
|
||||
}
|
||||
}
|
||||
|
||||
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
|
||||
if (tagType === 'ckb') {
|
||||
// 触发通用加好友(无特定 personId,使用全局 CKB Key)
|
||||
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
|
||||
return
|
||||
}
|
||||
|
||||
// 小程序内部路径(pagePath 或 url 以 /pages/ 开头)
|
||||
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
|
||||
if (internalPath) {
|
||||
wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) })
|
||||
return
|
||||
}
|
||||
|
||||
// 外部 URL:优先用 wx.openLink 在浏览器打开,旧版微信降级复制
|
||||
if (url) {
|
||||
if (typeof wx.openLink === 'function') {
|
||||
wx.openLink({
|
||||
url,
|
||||
fail: () => {
|
||||
// openLink 不支持(如不在微信内),降级复制
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
|
||||
},
|
||||
|
||||
// 点击正文图片 → 全屏预览
|
||||
onImageTap(e) {
|
||||
const src = e.currentTarget.dataset.src
|
||||
if (!src) return
|
||||
wx.previewImage({ current: src, urls: [src] })
|
||||
},
|
||||
|
||||
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资
|
||||
onMentionTap(e) {
|
||||
const userId = e.currentTarget.dataset.userId
|
||||
@@ -542,7 +533,9 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
|
||||
async _doMentionAddFriend(targetUserId, targetNickname) {
|
||||
const app = getApp()
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
@@ -579,6 +572,7 @@ Page({
|
||||
})
|
||||
return
|
||||
}
|
||||
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
@@ -661,64 +655,46 @@ Page({
|
||||
const { section, sectionId, sectionMid } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const safeSectionTitle = this.getSafeShareText(section?.title || '')
|
||||
const shareTitle = safeSectionTitle
|
||||
? `📚 ${safeSectionTitle.length > 20 ? safeSectionTitle.slice(0, 20) + '...' : safeSectionTitle}`
|
||||
const 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: '/assets/share-cover.png'
|
||||
path: ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
|
||||
// 不设置 imageUrl,使用当前阅读页截图作为分享卡片中间图片
|
||||
}
|
||||
},
|
||||
|
||||
// 分享到朋友圈:使用安全模板,避免正文敏感词触发平台风控提示
|
||||
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
|
||||
onShareTimeline() {
|
||||
const { sectionId, sectionMid, shareTimelineTitle, section } = this.data
|
||||
const { section, sectionId, sectionMid, chapterTitle } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const safeSectionTitle = this.getSafeShareText(section?.title || '')
|
||||
const safePreviewTitle = this.getSafeShareText(shareTimelineTitle || '')
|
||||
// 优先使用章节名,兜底使用预览文案;都为空时使用固定安全标题
|
||||
const baseTitle = safeSectionTitle || safePreviewTitle || 'Soul创业派对|真实商业案例'
|
||||
const title = baseTitle.length > 32 ? `${baseTitle.slice(0, 32)}...` : baseTitle
|
||||
const articleTitle = (section?.title || chapterTitle || '').trim()
|
||||
const title = articleTitle
|
||||
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
|
||||
: 'Soul创业派对 - 真实商业故事'
|
||||
return { title, query: ref ? `${q}&ref=${ref}` : q }
|
||||
},
|
||||
|
||||
// 清洗分享文案,规避高风险收益承诺类词汇,降低平台风控误判
|
||||
getSafeShareText(text = '') {
|
||||
let safeText = String(text || '').trim()
|
||||
if (!safeText) return ''
|
||||
|
||||
// 统一替换常见风险词
|
||||
const riskyPatterns = [
|
||||
/90%\s*收益/gi,
|
||||
/百分之九十\s*收益/gi,
|
||||
/收益/gi,
|
||||
/锁定\s*\d+\s*天/gi,
|
||||
/锁定期/gi,
|
||||
/稳赚/gi,
|
||||
/保本/gi,
|
||||
/高回报/gi,
|
||||
/返利/gi,
|
||||
/理财/gi,
|
||||
/投资/gi
|
||||
]
|
||||
riskyPatterns.forEach((pattern) => {
|
||||
safeText = safeText.replace(pattern, '')
|
||||
})
|
||||
|
||||
// 去掉多余空白和标点残留
|
||||
safeText = safeText
|
||||
.replace(/[,。;、,::\-\s]{2,}/g, ' ')
|
||||
.replace(/^[,。;、,::\-\s]+|[,。;、,::\-\s]+$/g, '')
|
||||
.trim()
|
||||
|
||||
return safeText
|
||||
},
|
||||
|
||||
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
||||
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)
|
||||
}
|
||||
try {
|
||||
this.setData({ showLoginModal: true, agreeProtocol: false })
|
||||
} catch (e) {
|
||||
@@ -1165,16 +1141,14 @@ Page({
|
||||
// 跳转到上一篇
|
||||
goToPrev() {
|
||||
if (this.data.prevSection) {
|
||||
const q = this.data.prevSection.mid ? `mid=${this.data.prevSection.mid}` : `id=${this.data.prevSection.id}`
|
||||
wx.redirectTo({ url: `/pages/read/read?${q}` })
|
||||
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转到下一篇
|
||||
goToNext() {
|
||||
if (this.data.nextSection) {
|
||||
const q = this.data.nextSection.mid ? `mid=${this.data.nextSection.mid}` : `id=${this.data.nextSection.id}`
|
||||
wx.redirectTo({ url: `/pages/read/read?${q}` })
|
||||
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -42,19 +42,16 @@
|
||||
<view class="skeleton skeleton-5"></view>
|
||||
</view>
|
||||
|
||||
<!-- 完整内容 - 免费或已购买(支持 @ 高亮可点) -->
|
||||
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片) -->
|
||||
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
|
||||
<block wx:if="{{contentSegments.length}}">
|
||||
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index" wx:if="{{item && item.length}}">
|
||||
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
|
||||
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
|
||||
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="paragraph" wx:for="{{contentParagraphs}}" wx:key="index" wx:if="{{item}}">{{item}}</view>
|
||||
</block>
|
||||
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
|
||||
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
|
||||
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
|
||||
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
|
||||
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}">#{{seg.label}}</text>
|
||||
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 章节导航 -->
|
||||
<view class="chapter-nav">
|
||||
@@ -89,8 +86,8 @@
|
||||
<view class="action-section">
|
||||
<view class="action-row-inline">
|
||||
<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-icon-small">💬</text>
|
||||
<text class="action-text-small">推荐给好友</text>
|
||||
</button>
|
||||
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
|
||||
<text class="action-icon-small">🖼️</text>
|
||||
|
||||
@@ -188,6 +188,21 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 正文内 #链接标签 高亮可点 */
|
||||
.paragraph .link-tag {
|
||||
color: #FFD700;
|
||||
font-weight: 500;
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
/* 正文内图片 */
|
||||
.content-image {
|
||||
width: 100%;
|
||||
border-radius: 8rpx;
|
||||
margin: 16rpx 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user