chore: 以本地工作区为准全量快照同步 GitHub
包含小程序、管理端、soul-api、脚本与静态资源等当前本地全部已跟踪与新增文件(排除 .DS_Store 与 .obsidian)。 Made-with: Cursor
This commit is contained in:
@@ -25,7 +25,7 @@ class ChapterAccessManager {
|
||||
const res = await app.getConfig()
|
||||
if (res && res.success && res.prices) {
|
||||
return {
|
||||
prices: res.prices || { section: 1, fullbook: 9.9 },
|
||||
prices: res.prices || { section: 1, fullbook: 365 },
|
||||
shareRate: res.shareRate != null ? res.shareRate : 90
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ChapterAccessManager {
|
||||
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
|
||||
}
|
||||
return {
|
||||
prices: { section: 1, fullbook: 9.9 },
|
||||
prices: { section: 1, fullbook: 365 },
|
||||
shareRate: 90
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,52 +174,189 @@ function parseBlockToSegments(block, config) {
|
||||
return segs
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 <table> HTML 为结构化数据 { headers: string[], rows: string[][] }
|
||||
*/
|
||||
function parseTableHtml(tableHtml) {
|
||||
const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim())
|
||||
const headers = []
|
||||
const rows = []
|
||||
|
||||
const headMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i)
|
||||
if (headMatch) {
|
||||
const thRe = /<th[^>]*>([\s\S]*?)<\/th>/gi
|
||||
let m
|
||||
while ((m = thRe.exec(headMatch[1])) !== null) {
|
||||
headers.push(stripTags(m[1]))
|
||||
}
|
||||
}
|
||||
|
||||
const bodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
|
||||
const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml
|
||||
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
||||
let trM
|
||||
let isFirst = !headMatch
|
||||
while ((trM = trRe.exec(bodyContent)) !== null) {
|
||||
if (headMatch && trM[0] === (headMatch[0].match(/<tr[^>]*>[\s\S]*?<\/tr>/i) || [])[0]) continue
|
||||
const cells = []
|
||||
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
|
||||
let cM
|
||||
while ((cM = cellRe.exec(trM[1])) !== null) {
|
||||
cells.push(stripTags(cM[1]))
|
||||
}
|
||||
if (!cells.length) continue
|
||||
if (isFirst && !headers.length) {
|
||||
headers.push(...cells)
|
||||
isFirst = false
|
||||
} else {
|
||||
rows.push(cells)
|
||||
}
|
||||
}
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 中解析出 lines(纯文本行)和 segments(含富文本片段)
|
||||
* segment 新增类型:heading / quote / listItem
|
||||
* @param {string} html
|
||||
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签;assetBase 用于补全图片相对 URL
|
||||
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string }
|
||||
*/
|
||||
function parseHtmlToSegments(html, config) {
|
||||
const lines = []
|
||||
const segments = []
|
||||
|
||||
// 1. 块级标签换行,保留内联标签供后续解析
|
||||
let text = html
|
||||
// 0. 提取 <table> 块
|
||||
const tables = []
|
||||
let text = html.replace(/<table[^>]*>[\s\S]*?<\/table>/gi, (match) => {
|
||||
const idx = tables.length
|
||||
tables.push(parseTableHtml(match))
|
||||
return '\n__TABLE_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 1. 提取 <h2>/<h3> → heading 占位
|
||||
const headings = []
|
||||
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
|
||||
const idx = headings.length
|
||||
headings.push({ level: parseInt(lvl, 10), raw: inner.trim() })
|
||||
return '\n__H_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 2. 提取 <blockquote> → quote 占位
|
||||
const quotes = []
|
||||
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) {
|
||||
const idx = quotes.length
|
||||
quotes.push(inner.trim())
|
||||
return '\n__Q_' + idx + '__\n'
|
||||
})
|
||||
|
||||
// 3. 列表 → listItem 占位(保留 ordered 标记)
|
||||
var olDepth = 0
|
||||
var olCounter = 0
|
||||
text = text.replace(/<ol[^>]*>/gi, function () { olDepth++; olCounter = 0; return '\n' })
|
||||
text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' })
|
||||
text = text.replace(/<ul[^>]*>/gi, '\n')
|
||||
text = text.replace(/<\/ul>/gi, '\n')
|
||||
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
|
||||
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
|
||||
if (!cleaned) return '\n'
|
||||
if (olDepth > 0) {
|
||||
olCounter++
|
||||
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
|
||||
}
|
||||
return '\n__LI_U__ ' + cleaned + '\n'
|
||||
})
|
||||
|
||||
// 4. 块级标签换行
|
||||
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
||||
text = text.replace(/<p[^>]*>/gi, '')
|
||||
text = text.replace(/<\/p>/gi, '\n')
|
||||
text = text.replace(/<div[^>]*>/gi, '')
|
||||
text = text.replace(/<\/div>/gi, '\n')
|
||||
text = text.replace(/<br\s*\/?>/gi, '\n')
|
||||
text = text.replace(/<\/?h[1-6][^>]*>/gi, '\n')
|
||||
text = text.replace(/<\/?blockquote[^>]*>/gi, '\n')
|
||||
text = text.replace(/<\/?ul[^>]*>/gi, '\n')
|
||||
text = text.replace(/<\/?ol[^>]*>/gi, '\n')
|
||||
text = text.replace(/<li[^>]*>/gi, '• ')
|
||||
text = text.replace(/<\/li>/gi, '\n')
|
||||
text = text.replace(/<hr\s*\/?>/gi, '\n')
|
||||
|
||||
// 2. 逐段解析
|
||||
const blocks = text.split(/\n+/)
|
||||
for (const block of blocks) {
|
||||
// 5. 逐段解析
|
||||
var blocks = text.split(/\n+/)
|
||||
for (var bi = 0; bi < blocks.length; bi++) {
|
||||
var block = blocks[bi]
|
||||
if (!block.trim()) continue
|
||||
|
||||
let blockSegs = parseBlockToSegments(block, config)
|
||||
// table
|
||||
var tblM = block.trim().match(/^__TABLE_(\d+)__$/)
|
||||
if (tblM) {
|
||||
var tbl = tables[parseInt(tblM[1], 10)]
|
||||
if (tbl) {
|
||||
lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n'))
|
||||
segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// heading
|
||||
var hM = block.trim().match(/^__H_(\d+)__$/)
|
||||
if (hM) {
|
||||
var h = headings[parseInt(hM[1], 10)]
|
||||
if (h) {
|
||||
var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(hText)
|
||||
segments.push([{ type: 'heading', level: h.level, text: hText }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// quote
|
||||
var qM = block.trim().match(/^__Q_(\d+)__$/)
|
||||
if (qM) {
|
||||
var qHtml = quotes[parseInt(qM[1], 10)]
|
||||
if (qHtml) {
|
||||
var qText = decodeEntities(
|
||||
qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim()
|
||||
).replace(/\n{2,}/g, '\n')
|
||||
lines.push(qText)
|
||||
segments.push([{ type: 'quote', text: qText }])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ordered list item
|
||||
var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/)
|
||||
if (liOM) {
|
||||
var liNum = parseInt(liOM[1], 10)
|
||||
var liContent = liOM[2].trim()
|
||||
var liSegs = parseBlockToSegments(liContent, config)
|
||||
var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(liNum + '. ' + liText)
|
||||
segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }])
|
||||
continue
|
||||
}
|
||||
|
||||
// unordered list item
|
||||
var liUM = block.match(/^__LI_U__\s*(.*)/)
|
||||
if (liUM) {
|
||||
var liContent2 = liUM[1].trim()
|
||||
var liSegs2 = parseBlockToSegments(liContent2, config)
|
||||
var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim())
|
||||
lines.push(liText2)
|
||||
segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }])
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
var blockSegs = parseBlockToSegments(block, config)
|
||||
if (!blockSegs.length) continue
|
||||
|
||||
// 纯图片行独立成段
|
||||
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
|
||||
lines.push('')
|
||||
segments.push(blockSegs)
|
||||
continue
|
||||
}
|
||||
|
||||
// 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx)
|
||||
if (config && (config.persons?.length || config.linkTags?.length)) {
|
||||
const expanded = []
|
||||
for (const seg of blockSegs) {
|
||||
var expanded = []
|
||||
for (var si = 0; si < blockSegs.length; si++) {
|
||||
var seg = blockSegs[si]
|
||||
if (seg.type === 'text' && seg.text) {
|
||||
const sub = matchLineToSegments(seg.text, config)
|
||||
expanded.push(...sub)
|
||||
expanded.push.apply(expanded, matchLineToSegments(seg.text, config))
|
||||
} else {
|
||||
expanded.push(seg)
|
||||
}
|
||||
@@ -227,8 +364,7 @@ function parseHtmlToSegments(html, config) {
|
||||
blockSegs = expanded
|
||||
}
|
||||
|
||||
// 行纯文本用于 lines(previewParagraphs 降级展示)
|
||||
const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
|
||||
var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
|
||||
lines.push(lineText)
|
||||
segments.push(blockSegs)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const READ_PATH = '/pages/read/read'
|
||||
|
||||
/** 阅读页 mpUi 兜底(pagePopupItems / readPage 均无或为空时) */
|
||||
const READ_PAGE_FALLBACK = {
|
||||
beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
|
||||
/** 已不在付费墙展示长说明;保留短句供兼容旧配置拉取 */
|
||||
beforeLoginHint: '',
|
||||
singlePageTitle: '解锁完整内容',
|
||||
singlePagePaywallHint:
|
||||
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致
|
||||
* 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时)
|
||||
*/
|
||||
const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖']
|
||||
const PART_ICONS = ['🧭', '🌍', '⚡', '💎', '🏆', '🚀', '🎯', '🔑', '🌟', '🎪']
|
||||
|
||||
/** 正文篇在列表中的从 0 开始的序号 → emoji(与 ChapterTree bodyPartOrdinal 一致) */
|
||||
function partEmojiForBodyIndex(bodyIndex) {
|
||||
|
||||
@@ -412,6 +412,90 @@ function checkRule_Withdraw(rules) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览 N 章后弹出 365 读书会支付引导
|
||||
* 规则匹配:triggerConditions 包含 view_n_chapters_pay365 或 browse_n_chapters
|
||||
* 阈值来自 actionConfig.chapterThreshold(默认 3)
|
||||
* actionConfig.revealMode:modal 进入章节即延时弹窗;anchor 仅展示锚点条,用户点击后再弹窗/支付
|
||||
* actionConfig.anchorLabel:锚点条文案(可选)
|
||||
*/
|
||||
function findRuleByTriggerCondition(rules, conditionKey) {
|
||||
return rules.find(r => {
|
||||
if (r.completed) return false
|
||||
const tc = r.triggerConditions
|
||||
if (Array.isArray(tc)) return tc.includes(conditionKey)
|
||||
if (typeof tc === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(tc)
|
||||
if (Array.isArray(parsed)) return parsed.includes(conditionKey)
|
||||
} catch (_) {}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function parseActionConfig(rule) {
|
||||
if (!rule) return {}
|
||||
const ac = rule.actionConfig
|
||||
if (ac && typeof ac === 'object' && !Array.isArray(ac)) return ac
|
||||
if (typeof ac === 'string' && ac.trim()) {
|
||||
try {
|
||||
const p = JSON.parse(ac)
|
||||
if (p && typeof p === 'object') return p
|
||||
} catch (_) {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function checkRule_ViewNChaptersPay365(rules) {
|
||||
const rule = findRuleByTriggerCondition(rules, 'view_n_chapters_pay365')
|
||||
|| findRuleByTriggerCondition(rules, 'browse_n_chapters')
|
||||
if (!rule) return null
|
||||
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
|
||||
const app = getAppInstance()
|
||||
if (!app) return null
|
||||
if (app.globalData.hasFullBook || app.globalData.isVip) return null
|
||||
|
||||
const ac = parseActionConfig(rule)
|
||||
const threshold = (Number(ac.chapterThreshold) > 0) ? Number(ac.chapterThreshold) : 3
|
||||
const fullRead =
|
||||
typeof app.getReadCount === 'function' ? app.getReadCount() : app.globalData.readCount || 0
|
||||
const browseDistinct =
|
||||
typeof app.getBrowseDistinctChapterCount === 'function' ? app.getBrowseDistinctChapterCount() : 0
|
||||
const readCount = Math.max(Number(fullRead) || 0, Number(browseDistinct) || 0)
|
||||
if (readCount < threshold) return null
|
||||
|
||||
if (isInCooldown('view_n_pay365')) return null
|
||||
setCooldown('view_n_pay365')
|
||||
|
||||
const ctaKind = String(ac.ctaKind || 'pay365')
|
||||
const amount = ctaKind === 'pay365' ? 365 : null
|
||||
|
||||
const rm = ac.revealMode
|
||||
let revealMode = rm === 'modal' || rm === 'anchor' ? rm : null
|
||||
if (!revealMode) {
|
||||
revealMode = 'modal'
|
||||
}
|
||||
const anchorLabel = trimStr(ac.anchorLabel)
|
||||
|
||||
return {
|
||||
ruleId: 'view_n_pay365',
|
||||
serverRuleId: rule.id,
|
||||
title: rule.title || '加入 365 读书会',
|
||||
message: rule.description || `你已阅读 ${readCount} 个章节,加入 365 读书会可解锁全部内容,一年内不限次阅读。`,
|
||||
confirmText: amount ? `¥${amount} 立即加入` : '去了解',
|
||||
cancelText: '再看看',
|
||||
action: ctaKind === 'pay365' ? 'pay365' : 'navigate',
|
||||
target: null,
|
||||
amount,
|
||||
revealMode,
|
||||
anchorLabel,
|
||||
}
|
||||
}
|
||||
|
||||
function checkRulesSync(scene, rules) {
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
@@ -431,6 +515,8 @@ function checkRulesSync(scene, rules) {
|
||||
return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
|
||||
case 'before_join_party':
|
||||
return checkRule_JoinParty(rules)
|
||||
case 'chapter_read':
|
||||
return checkRule_ViewNChaptersPay365(rules)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -452,6 +538,12 @@ function executeRule(rule, pageInstance) {
|
||||
if (typeof pageInstance.showPhoneBinding === 'function') {
|
||||
pageInstance.showPhoneBinding()
|
||||
}
|
||||
} else if (rule.action === 'pay365' && pageInstance) {
|
||||
if (typeof pageInstance.handlePurchaseFullBook === 'function') {
|
||||
pageInstance.handlePurchaseFullBook()
|
||||
} else if (typeof pageInstance.processPayment === 'function') {
|
||||
pageInstance.processPayment('fullbook', null, rule.amount || 365)
|
||||
}
|
||||
}
|
||||
if (rule.serverRuleId) {
|
||||
markRuleCompleted(rule.serverRuleId)
|
||||
@@ -485,8 +577,34 @@ async function checkAndExecute(scene, pageInstance) {
|
||||
rule = checkRulesSync(scene, rules)
|
||||
}
|
||||
if (rule) {
|
||||
if (
|
||||
scene === 'chapter_read' &&
|
||||
rule.ruleId === 'view_n_pay365' &&
|
||||
rule.revealMode === 'anchor' &&
|
||||
rule.action === 'pay365' &&
|
||||
pageInstance &&
|
||||
typeof pageInstance.onPay365MarketingAnchor === 'function'
|
||||
) {
|
||||
setTimeout(() => pageInstance.onPay365MarketingAnchor(rule), 800)
|
||||
return
|
||||
}
|
||||
setTimeout(() => executeRule(rule, pageInstance), 800)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }
|
||||
/** 阅读页同步营销态:与 chapter_read 同一套条件,不自动弹窗 */
|
||||
async function getChapterReadMarketingRule() {
|
||||
const rules = await loadRules()
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
return checkRulesSync('chapter_read', rules)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkRules: checkRulesSync,
|
||||
executeRule,
|
||||
checkAndExecute,
|
||||
loadRules,
|
||||
markRuleCompleted,
|
||||
getChapterReadMarketingRule,
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ function getReferralCodeForPay(app) {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 章节 / 全书支付描述(与 read 页原逻辑一致) */
|
||||
/** 章节 / 365读书会(fullbook)支付描述(与 read 页一致) */
|
||||
function buildSectionPayDescription(productType, sectionId, sectionTitle) {
|
||||
if (productType === 'fullbook') return '《一场Soul的创业实验》全书'
|
||||
if (productType === 'fullbook') return '加入读书会'
|
||||
if (productType === 'section') {
|
||||
const t = sectionTitle || sectionId || ''
|
||||
const short = t.length > 20 ? t.slice(0, 20) + '...' : t
|
||||
|
||||
Reference in New Issue
Block a user