diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md new file mode 100644 index 00000000..d91bef0a --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md @@ -0,0 +1,38 @@ +# 内容排版层级改造 (2026-04-07) + +## 问题 +28套私域商业模式章节在小程序中显示为纯文本流,所有内容同样字体大小,无层级感。h2/h3 被扁平化为普通 text,列表项带孤立 bullet 点(•),无缩进。 + +## 解决方案 + +### contentParser.js 新增段落类型 +- `heading` (level 2-6):从 `
` 提取 +- `listItem` (ordered/unordered):从 `` 提取,区分 ` ` 和 `
` 上下文 +- 保留 table / image / text / mention / linkTag + +### 解析流程(顺序关键) +1. 提取 `
` → 占位 +2. 提取 `
`~`
` → 占位 +3. 提取 `
` → 占位 +4. 处理 `/
/
- ` → 带标记的占位 +5. 清理其余块级标签 +6. 按 `\n+` 分段,逐段识别占位符类型 + +### WXML 渲染 +- heading:`seg-heading seg-hN` 类名,text user-select +- quote:`seg-quote`,左边框 + 背景 +- listItem:`seg-list-item`,flex 布局(marker + text) +- image:独立 paragraph +- 普通段落:原有逻辑 + +### WXSS 关键样式 +- `.seg-h2`: 40rpx, 底部分隔线 +- `.seg-h3`: 36rpx, 青色 (#00CED1) +- `.seg-quote`: 左 6rpx 青色边框 + 微背景 +- `.seg-list-item`: flex, marker 44rpx 宽,文本 30rpx + +### 上传脚本注意事项 +- `markdown` 库 + `tables` 扩展生成的 HTML 天然保留语义标签 +- `clean_html()` 要保留 h/blockquote/ul/ol/li,只清 `
`、空标签、`- ` 内 `
` +- 上传后必须通过 admin API PUT `/api/db/book` 触发 `cache.InvalidateChapterContent()` diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md b/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md new file mode 100644 index 00000000..a8c8b1b0 --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md @@ -0,0 +1,15 @@ +# 阅读页付费墙三态与营销句闭环(2026-04-11) + +## 问题 / 背景 + +- 登录前/后、朋友圈单页付费墙样式与「购买本章」框不一致;未登录长文案过多;底部营销句与已解锁分享区兜底不一致。 + +## 处理要点 + +- `read.wxml`:`paywall-marketing-box` 统一 90% 主标题;`readUi.shareTipLine` 作唯一底部营销句;已解锁区 `share-tip-text` 仅绑 `readUi.shareTipLine`,去掉旧兜底。 +- `read.js`:`readBeforeLoginHint` 默认置空(长说明改走配置或不再展示)。 +- 需求文档 `内容管理20260411.plan.md` 补充闭环与验收说明。 + +## 可复用规则 + +- 阅读页展示文案优先 `read_preview_ui`(章节接口合并);兜底在 `READ_UI_DEFAULTS` 与 `chapter_preview.go` 保持同步;管理端 ContentPage 模板同键。 diff --git a/.cursor/agent/小程序开发工程师/evolution/索引.md b/.cursor/agent/小程序开发工程师/evolution/索引.md index dba0947d..2637db19 100644 --- a/.cursor/agent/小程序开发工程师/evolution/索引.md +++ b/.cursor/agent/小程序开发工程师/evolution/索引.md @@ -14,3 +14,4 @@ | 2026-03-16 | 编辑资料页分享名片:转发/朋友圈特殊处理,Canvas 绘制封面,标题「昵称+为您分享名片」 | [2026-03-16.md](./2026-03-16.md) | | 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) | | 2026-03-30 | 阅读页 `navigateToMiniProgram` 时 path 追加 `phone`(已登录且已绑定) | [2026-03-30.md](./2026-03-30.md) | +| 2026-04-11 | 阅读付费墙三态框体统一、`shareTipLine` 与文末分享区一致、需求文档闭环 | [2026-04-11-阅读付费墙三态与营销句闭环.md](./2026-04-11-阅读付费墙三态与营销句闭环.md) | diff --git a/.cursor/skills/change-checklist/SKILL.md b/.cursor/skills/change-checklist/SKILL.md index 02118987..28deb401 100644 --- a/.cursor/skills/change-checklist/SKILL.md +++ b/.cursor/skills/change-checklist/SKILL.md @@ -18,6 +18,8 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a 配合 **.cursor/rules/soul-change-checklist.mdc** 使用:规则里是必过清单,本 Skill 是完整流程与思路。 +**正式部署前**:若本次变更涉及小程序/管理端 API 基址或环境变量,先过 **`.cursor/skills/security-server-ops/SKILL.md` §1.3**(默认 `https://soulapi.quwanzhi.com`,禁止误留 `souldev` 为发版默认)。 + --- ## 2. 核心思路:按「改动点」扫关联层 diff --git a/.cursor/skills/security-server-ops/SKILL.md b/.cursor/skills/security-server-ops/SKILL.md index 34971904..3243668f 100644 --- a/.cursor/skills/security-server-ops/SKILL.md +++ b/.cursor/skills/security-server-ops/SKILL.md @@ -40,6 +40,28 @@ description: Soul 服务器操作与部署。部署脚本、SSH、宝塔、环 **密码**:从 `DEPLOY_PASSWORD` 或各脚本 `get_cfg()` 默认值读取,**不在此文档记录**。 +### 1.3 永平正式 API 默认域名(部署前必核对) + +**约定**:本仓库面向线上联调与发版时,**默认后端 API 基址**为 **`https://soulapi.quwanzhi.com`**(无尾部斜杠)。管理端静态站为 `https://souladmin.quwanzhi.com/`。测服 `souldev.quwanzhi.com` 仅用于明确标注的测试迭代,**不得作为默认常量留在要合并/要部署的分支上**。 + +| 位置 | 须确认的内容 | +|------|----------------| +| 小程序 | `miniprogram/app.js` 中 `API_BASE_URL_PROD`、`API_BASE_URL_DEV` 是否指向正式 API(默认均应为 `https://soulapi.quwanzhi.com`,临时改测服须在发版前改回) | +| 管理端构建 | `soul-admin/.env.production` 的 `VITE_API_BASE_URL`;未设置时以 `soul-admin/src/api/client.ts` 的 `DEFAULT_API_BASE` 为准 | +| 后端默认 | `soul-api/internal/config/config.go` 中未配置环境变量时的 `API_BASE_URL` 默认值 | +| 一键部署冒烟 | `scripts/deploy_kr_btapi_verify.py` 使用 `VERIFY_API_BASE` 环境变量,默认 `https://soulapi.quwanzhi.com` | + +**每次执行正式部署(`deploy_kr_btapi_verify.py` 或 `soul-api/master.py` + `soul-admin/master.py`)之前**,在仓库根执行下列命令,确认无意外指向测服: + +```bash +cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平" +rg -n "API_BASE_URL_PROD|API_BASE_URL_DEV|DEFAULT_API_BASE|VITE_API_BASE_URL|VERIFY_API_BASE" \ + miniprogram/app.js soul-admin/src/api/client.ts soul-admin/.env.production scripts/deploy_kr_btapi_verify.py +rg -n "souldev\.quwanzhi\.com" miniprogram/app.js soul-admin/.env.production 2>/dev/null || true +``` + +- **验收**:`API_BASE_URL_PROD` 为 `https://soulapi.quwanzhi.com`;`soul-admin/.env.production` 中 `VITE_API_BASE_URL` 为同址(或依赖 `DEFAULT_API_BASE` 同址);`miniprogram/app.js` 中除注释外不应残留 `souldev.quwanzhi.com` 作为默认发版配置。 + --- ## 二、部署脚本索引 diff --git a/miniprogram/app.js b/miniprogram/app.js index 14f12284..66ba25ba 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -10,13 +10,11 @@ const mpPagePopups = require('./utils/mpPagePopups.js') const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' const DEFAULT_MCH_ID = '1318592501' const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' -// 线上正式域名(真机/体验版/正式版强制使用此地址) -// const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com' -const API_BASE_URL_PROD = 'https://souldev.quwanzhi.com' +// 线上正式 API(真机/体验版/正式版)。永平仓库默认正式域;部署前核对见 .cursor/skills/security-server-ops/SKILL.md +const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com' -// 本地开发地址(仅开发者工具模拟器生效;真机自动回退 PROD) -// const API_BASE_URL_DEV = 'http://127.0.0.1:9100' -const API_BASE_URL_DEV = 'https://souldev.quwanzhi.com' +// 开发者工具默认与正式一致;若需连测服可改为 https://souldev.quwanzhi.com 或本机 http://127.0.0.1:9100 +const API_BASE_URL_DEV = 'https://soulapi.quwanzhi.com' // 运行时自动判断:非开发工具(真机/体验版/正式版)强制用 PROD,避免 localhost 打进上传包导致白屏 const _isDevTools = (typeof __wxConfig !== 'undefined' && __wxConfig.platform === 'devtools') @@ -62,7 +60,7 @@ App({ // 购买记录 purchasedSections: [], hasFullBook: false, - // VIP 会员(365天,包含增值版免费;与 hasFullBook=9.9 买断不同) + // VIP 会员(365天,包含增值版免费;与 hasFullBook=365读书会权益不同) isVip: false, vipExpireDate: '', @@ -570,6 +568,11 @@ App({ if (!base) return // 本机 API 无稳定 WSS 时跳过,减少控制台报错与无效重连 if (/^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?$/i.test(base)) return + try { + const h = new URL(base.startsWith('http') ? base : 'https://' + base).hostname + // 开发/预览域名未部署 WSS 时跳过(避免 DevTools 反复报 WebSocket failed) + if (h === 'souldev.quwanzhi.com' || h.endsWith('.local')) return + } catch (_) {} const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram' if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer) @@ -1023,10 +1026,8 @@ App({ }, success: (res) => { const data = res.data - const rejectWithBody = (message, body) => { - const err = new Error(message) - if (body != null && typeof body === 'object') err.response = body - reject(err) + const rejectWithBody = (message) => { + reject(new Error(message)) } if (res.statusCode === 200) { if (data && data.success === false) { @@ -1035,7 +1036,7 @@ App({ this.logout() } showError(msg) - rejectWithBody(msg, data) + rejectWithBody(msg) return } resolve(data) @@ -1044,14 +1045,12 @@ App({ if (res.statusCode === 401) { this.logout() showError('未授权,请重新登录') - const err = new Error('未授权') - if (data != null && typeof data === 'object') err.response = data - reject(err) + reject(new Error('未授权')) return } const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败') showError(msg) - rejectWithBody(msg, data && typeof data === 'object' ? data : undefined) + rejectWithBody(msg) }, fail: (err) => { const msg = (err && err.errMsg) @@ -1090,11 +1089,9 @@ App({ if (pending[dedupKey]) return pending[dedupKey].promise } - const promise = this._requestOnce(url, options, silent).catch(async (err) => { + const promise = this._requestOnce(url, options, silent).catch((err) => { const msg = (err && err.message) ? err.message : '网络异常,请重试' - const next = new Error(msg) - if (err && err.response != null) next.response = err.response - throw next + throw new Error(msg) }) if (method === 'GET') { @@ -1408,6 +1405,22 @@ App({ return (this.globalData.readSectionIds || []).length }, + /** 最近打开过的不同章节数(含仅预览),与 getReadCount 取大用于「浏览 N 章 →365」规则 */ + getBrowseDistinctChapterCount() { + try { + let list = wx.getStorageSync('recent_section_opens') + if (!Array.isArray(list)) return 0 + const ids = new Set() + for (let i = 0; i < list.length; i++) { + const x = list[i] + if (x && x.id) ids.add(String(x.id)) + } + return ids.size + } catch (e) { + return 0 + } + }, + // 获取章节总数 getTotalSections() { return this.globalData.totalSections diff --git a/miniprogram/app.json b/miniprogram/app.json index 8dcb6322..1b95fd49 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -21,9 +21,11 @@ "pages/addresses/edit", "pages/withdraw-records/withdraw-records", "pages/wallet/wallet", + "pages/super-crm/super-crm", "pages/vip/vip", "pages/member-detail/member-detail", "pages/mentors/mentors", + "pages/match-history/match-history", "pages/mentor-detail/mentor-detail", "pages/profile-show/profile-show", "pages/profile-edit/profile-edit", diff --git a/miniprogram/pages/about/about.js b/miniprogram/pages/about/about.js index d45b356c..5c846361 100644 --- a/miniprogram/pages/about/about.js +++ b/miniprogram/pages/about/about.js @@ -27,7 +27,7 @@ Page({ { name: '真实的赚钱', chapters: 20 }, { name: '真实的社会', chapters: 9 } ], - price: 9.9 + price: 365 } }, diff --git a/miniprogram/pages/about/about.wxml b/miniprogram/pages/about/about.wxml index aaee8f00..99984659 100644 --- a/miniprogram/pages/about/about.wxml +++ b/miniprogram/pages/about/about.wxml @@ -50,7 +50,7 @@
¥{{bookInfo.price}} -全书价格 +读书会价格 diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js index cfe5145e..54f32822 100644 --- a/miniprogram/pages/chapters/chapters.js +++ b/miniprogram/pages/chapters/chapters.js @@ -12,49 +12,44 @@ const { isSafeImageSrc } = require('../../utils/imageUrl.js') Page({ data: { - // 系统信息 statusBarHeight: 44, navBarHeight: 88, - // 用户状态 isLoggedIn: false, hasFullBook: false, isVip: false, purchasedSections: [], - // 懒加载:篇章列表(不含章节详情),展开时再请求 chapters-by-part + // 多书架构 + bookList: [], + expandedBookId: null, + + // 当前展开书的篇章数据(按 bookId 索引) + _bookPartsData: {}, + totalSections: 0, bookData: [], - // 展开状态 expandedPart: null, bookCollapsed: false, - // 已加载的篇章章节缓存 { partId: chapters } _loadedChapters: {}, - - // 小三角点击动画:当前触发的子章 id(与 chapter.id 比对) _triangleAnimating: '', - // 固定模块 id -> mid(序言/尾声/附录,供 goToRead 传 mid) fixedSectionsMap: {}, - // 附录 appendixList: [ { id: 'appendix-1', title: '附录1|Soul派对房精选对话' }, { id: 'appendix-2', title: '附录2|创业者自检清单' }, { id: 'appendix-3', title: '附录3|本书提到的工具和资源' } ], - // book/parts 加载中 partsLoading: true, - - // 功能配置(搜索开关) searchEnabled: true, + bookFreePreviewPercent: 100, - // mp_config.mpUi.chaptersPage - chaptersBookTitle: '一场SOUL的创业实验场', - chaptersBookSubtitle: '来自Soul派对房的真实商业故事', + chaptersBookTitle: '一场Soul的创业实验', + chaptersBookSubtitle: '来自派对房的真实故事', chaptersNewBadgeText: 'NEW' }, @@ -66,7 +61,7 @@ Page({ }) this.updateUserStatus() this.loadVipStatus() - this.loadParts() + this.loadBooks() this.loadFeatureConfig() }, @@ -75,9 +70,9 @@ Page({ const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {} const newBadgeText = String(c.newBadgeText || c.sectionNewBadgeText || h.latestSectionTitle || 'NEW').trim() || 'NEW' this.setData({ - chaptersBookTitle: String(c.bookTitle || '一场SOUL的创业实验场').trim() || '一场SOUL的创业实验场', - chaptersBookSubtitle: String(c.bookSubtitle || '来自Soul派对房的真实商业故事').trim() || - '来自Soul派对房的真实商业故事', + chaptersBookTitle: String(c.bookTitle || '一场Soul的创业实验').trim() || '一场Soul的创业实验', + chaptersBookSubtitle: String(c.bookSubtitle || '来自派对房的真实故事').trim() || + '来自派对房的真实故事', chaptersNewBadgeText: newBadgeText }) }, @@ -90,6 +85,8 @@ Page({ try { if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') { this.setData({ searchEnabled: app.globalData.features.searchEnabled }) + const pct = app.globalData.bookFreePreviewPercent + if (typeof pct === 'number') this.setData({ bookFreePreviewPercent: pct }) this._applyChaptersMpUi() return } @@ -98,7 +95,9 @@ Page({ const searchEnabled = features.searchEnabled !== false if (!app.globalData.features) app.globalData.features = {} app.globalData.features.searchEnabled = searchEnabled - this.setData({ searchEnabled }) + const previewPct = (typeof res?.bookFreePreviewPercent === 'number') ? res.bookFreePreviewPercent : 100 + app.globalData.bookFreePreviewPercent = previewPct + this.setData({ searchEnabled, bookFreePreviewPercent: previewPct }) this._applyChaptersMpUi() } catch (e) { this.setData({ searchEnabled: true }) @@ -106,11 +105,56 @@ Page({ } }, - // 懒加载:仅拉取篇章列表 + totalSections + fixedSections(book/parts,不再用 all-chapters) - async loadParts() { + async loadBooks() { + this.setData({ partsLoading: true }) + let books = [] + try { + const res = await app.request({ url: '/api/miniprogram/book/books', silent: true }) + if (res?.success && Array.isArray(res.books)) { + books = res.books + } + } catch (e) { + console.log('[Chapters] 加载书列表失败,使用默认书:', e?.message || e) + } + if (books.length === 0) { + books = [{ + id: 1, bookKey: 'main', + title: this.data.chaptersBookTitle || '一场Soul的创业实验', + subtitle: this.data.chaptersBookSubtitle || '来自派对房的真实故事', + icon: '', iconEmoji: '📖', sortOrder: 0, sectionCount: 0, + }] + } + this.setData({ + bookList: books.map(b => ({ + id: b.id, + bookKey: b.bookKey || 'main', + title: b.title, + subtitle: b.subtitle || '', + icon: b.icon || '', + iconEmoji: b.iconEmoji || '', + sortOrder: b.sortOrder || 0, + sectionCount: b.sectionCount || 0, + })), + expandedBookId: null, + partsLoading: false, + }) + }, + + async loadPartsForBook(bookId) { + const effectiveBookId = bookId > 0 ? bookId : 1 + if (effectiveBookId > 0 && this.data._bookPartsData[effectiveBookId]) { + this.setData({ + bookData: this.data._bookPartsData[effectiveBookId].parts, + totalSections: this.data._bookPartsData[effectiveBookId].total, + fixedSectionsMap: this.data._bookPartsData[effectiveBookId].fixedMap, + appendixList: this.data._bookPartsData[effectiveBookId].appendixList, + }) + return + } this.setData({ partsLoading: true }) try { - const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true }) + const url = `/api/miniprogram/book/parts${effectiveBookId > 0 ? '?bookId=' + effectiveBookId : ''}` + const res = await app.request({ url, silent: true }) let parts = [] let totalSections = 0 let fixedSections = [] @@ -142,17 +186,23 @@ Page({ subtitle: p.subtitle || '', chapterCount: p.chapterCount || 0, chapters: [], - alwaysShow: (p.title || '').indexOf('每日派对干货') > -1 + alwaysShow: false } }) app.globalData.totalSections = totalSections + if (effectiveBookId > 0) { + const cached = { ...this.data._bookPartsData } + cached[effectiveBookId] = { parts: bookData, total: totalSections, fixedMap, appendixList } + this.setData({ _bookPartsData: cached }) + } this.setData({ bookData, totalSections, fixedSectionsMap: fixedMap, appendixList, _loadedChapters: {}, - partsLoading: false + partsLoading: false, + expandedPart: null }) } catch (e) { console.log('[Chapters] 加载篇章失败:', e) @@ -194,8 +244,20 @@ Page({ }) }) const chapters = Array.from(chMap.values()) - chapters.forEach(ch => ch.sections.reverse()) - // 目录子章下列表:默认最多展示 5 条,点小三角每次再展开 5 条 + + // 按免费展示比例标记:超出比例的章节标记为需付费 + const freePercent = this.data.bookFreePreviewPercent ?? 100 + if (freePercent < 100) { + let allSections = [] + chapters.forEach(ch => { allSections = allSections.concat(ch.sections) }) + const freeCount = Math.floor(allSections.length * freePercent / 100) + allSections.forEach((s, idx) => { + if (idx >= freeCount && !s.isFree) { + s.needPay = true + } + }) + } + chapters.forEach((ch) => { const n = ch.sections.length ch.sectionVisibleLimit = n === 0 ? 0 : Math.min(5, n) @@ -218,8 +280,28 @@ Page({ } }, + async toggleBook(e) { + const bookId = Number(e.currentTarget.dataset.bookId) + trackClick('chapters', 'tab_click', `book_${bookId}`) + const isExpanding = this.data.expandedBookId !== bookId + this.setData({ + expandedBookId: isExpanding ? bookId : null, + expandedPart: null + }) + if (isExpanding) { + await this.loadPartsForBook(bookId) + // 自动展开第一个篇章(二级默认展开) + if (this.data.bookData.length > 0) { + const firstPartId = this.data.bookData[0].id + this.setData({ expandedPart: firstPartId }) + await this.loadChaptersByPart(firstPartId) + } + } + }, + onPullDownRefresh() { - this.loadParts() + this.setData({ _bookPartsData: {} }) + this.loadBooks() .then(() => wx.stopPullDownRefresh()) .catch(() => wx.stopPullDownRefresh()) }, @@ -239,7 +321,7 @@ Page({ this.loadVipStatus() }, - // 拉取 VIP 状态(isVip=会员,hasFullBook=9.9 买断) + // 拉取 VIP 状态(isVip=会员,hasFullBook=365读书会权益) async loadVipStatus() { const userId = app.globalData.userInfo?.id if (!userId) return diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml index 17538071..d91af392 100644 --- a/miniprogram/pages/chapters/chapters.wxml +++ b/miniprogram/pages/chapters/chapters.wxml @@ -1,5 +1,5 @@ - + @@ -18,8 +18,8 @@ - - + diff --git a/miniprogram/pages/read/read.wxss b/miniprogram/pages/read/read.wxss index e56a2954..41d21aa8 100644 --- a/miniprogram/pages/read/read.wxss +++ b/miniprogram/pages/read/read.wxss @@ -202,16 +202,73 @@ /* ===== 文章内容 ===== */ .article { - color: rgba(255, 255, 255, 0.85); - font-size: 34rpx; - line-height: 1.9; + color: rgba(255, 255, 255, 0.82); + font-size: 32rpx; + line-height: 1.85; } .paragraph { - margin-bottom: 48rpx; + margin-bottom: 36rpx; text-align: justify; } +/* --- heading 层级 --- */ +.seg-heading { + font-weight: 700; + color: #ffffff; + line-height: 1.4; +} +.seg-h2 { + font-size: 40rpx; + margin: 56rpx 0 28rpx; + padding-bottom: 16rpx; + border-bottom: 1rpx solid rgba(0, 206, 209, 0.18); +} +.seg-h3 { + font-size: 36rpx; + margin: 44rpx 0 20rpx; + color: #00CED1; +} +.seg-h4, .seg-h5, .seg-h6 { + font-size: 34rpx; + margin: 36rpx 0 16rpx; + color: rgba(255, 255, 255, 0.92); +} + +/* --- quote 引用 --- */ +.seg-quote { + margin: 28rpx 0; + padding: 20rpx 28rpx; + border-left: 6rpx solid rgba(0, 206, 209, 0.5); + background: rgba(255, 255, 255, 0.03); + border-radius: 0 12rpx 12rpx 0; + font-size: 30rpx; + color: rgba(255, 255, 255, 0.65); + line-height: 1.75; +} + +/* --- listItem 列表 --- */ +.seg-list-item { + display: flex; + align-items: flex-start; + margin-bottom: 16rpx; + padding-left: 8rpx; +} +.seg-list-marker { + flex-shrink: 0; + width: 44rpx; + font-size: 28rpx; + color: rgba(0, 206, 209, 0.7); + line-height: 1.85; + text-align: center; +} +.seg-list-text { + flex: 1; + font-size: 30rpx; + color: rgba(255, 255, 255, 0.8); + line-height: 1.75; +} + /* 正文内 @某人 高亮可点 */ .paragraph .mention { color: #00CED1; @@ -228,11 +285,62 @@ /* 正文内图片 */ .content-image { width: 100%; - border-radius: 8rpx; - margin: 16rpx 0; + border-radius: 12rpx; + margin: 24rpx 0; display: block; } +/* 正文内表格 */ +.table-scroll { + margin: 24rpx 0 36rpx; + border-radius: 16rpx; + overflow: hidden; +} + +.content-table { + display: inline-flex; + flex-direction: column; + min-width: 100%; + border: 1rpx solid rgba(0, 206, 209, 0.25); + border-radius: 16rpx; + overflow: hidden; +} + +.table-row { + display: flex; + border-bottom: 1rpx solid rgba(255, 255, 255, 0.06); +} + +.table-row:last-child { + border-bottom: none; +} + +.table-header-row { + background: rgba(0, 206, 209, 0.12); + border-bottom: 1rpx solid rgba(0, 206, 209, 0.2); +} + +.table-cell { + flex: 1; + min-width: 160rpx; + padding: 20rpx 24rpx; + font-size: 26rpx; + color: rgba(255, 255, 255, 0.8); + line-height: 1.5; + border-right: 1rpx solid rgba(255, 255, 255, 0.06); + word-break: break-word; +} + +.table-cell:last-child { + border-right: none; +} + +.table-header-cell { + font-weight: 600; + color: #00CED1; + font-size: 26rpx; +} + .preview { position: relative; } @@ -289,11 +397,85 @@ margin-bottom: 16rpx; } +.paywall-title-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; + margin-bottom: 28rpx; +} +/* 与「购买本章」同款底框:营销主标题单独成框 */ +.paywall-marketing-box { + margin-bottom: 24rpx; + padding: 28rpx 32rpx; + border-radius: 24rpx; + background: #2c2c2e; + border: 2rpx solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; +} +.paywall-title-row--boxed { + margin-bottom: 0; + justify-content: center; + text-align: center; +} +.paywall-icon--compact { + width: 112rpx; + height: 112rpx; + margin: 0 auto 24rpx; +} +.paywall-icon--compact + .paywall-marketing-box { + margin-top: 0; +} +.paywall-title--inline { + display: inline; + font-size: 34rpx; + font-weight: 600; + color: #ffffff; + line-height: 1.5; +} +.paywall-highlight-pct { + display: inline; + font-size: 46rpx; + font-weight: 800; + color: #FFD700; + margin: 0 6rpx; + line-height: 1.2; +} + /* 朋友圈单页:与完整小程序同款购买行,留白略紧 */ .paywall--single-preview { padding-top: 40rpx; padding-bottom: 40rpx; } +/* 单页简洁版:去大锁图标、标题+分润+单按钮纵向 */ +.paywall--compact-sp { + padding: 32rpx 28rpx 36rpx; +} +.paywall-sp-compact-title { + display: block; + font-size: 34rpx; + font-weight: 600; + color: #ffffff; + text-align: center; + line-height: 1.4; + margin-bottom: 16rpx; +} +.paywall-sp-compact-share { + display: block; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.52); + text-align: center; + line-height: 1.5; + margin-bottom: 28rpx; + padding: 0 8rpx; +} +.paywall-sp-compact-btns { + margin-bottom: 0 !important; +} +.purchase-btn--compact-sp { + padding: 26rpx 28rpx; + border-radius: 999rpx; +} .paywall--single-preview .paywall-icon { margin-bottom: 24rpx; } @@ -395,21 +577,6 @@ padding: 0 16rpx; } -.preview-percent-row { - margin: 16rpx 0 8rpx; - display: flex; - justify-content: center; -} - -.preview-percent-badge { - font-size: 24rpx; - color: #00CED1; - border: 2rpx solid rgba(0, 206, 209, 0.35); - border-radius: 999rpx; - padding: 8rpx 24rpx; - background: rgba(0, 206, 209, 0.08); -} - .purchase-btn--fulltext { justify-content: center; } @@ -420,6 +587,20 @@ width: 100%; } +.purchase-btn--animate { + transition: transform 0.15s ease; +} +.bounce-once { + animation: payBtnBounce 0.5s ease; +} +@keyframes payBtnBounce { + 0% { transform: scale(1); } + 20% { transform: scale(0.92); } + 50% { transform: scale(1.08); } + 80% { transform: scale(0.97); } + 100% { transform: scale(1); } +} + /* ===== 购买选项 ===== */ .purchase-options { display: flex; @@ -440,6 +621,61 @@ background: #2c2c2e; border: 2rpx solid rgba(255, 255, 255, 0.1); } +/* 365 锚点为主时:单章降为次要-outline,避免与 1 元主按钮视觉同级 */ +.purchase-section--secondary { + background: transparent; + border: 2rpx solid rgba(255, 255, 255, 0.22); + padding: 22rpx 28rpx; +} +.purchase-section--secondary .btn-label { + font-size: 26rpx; + color: rgba(255, 255, 255, 0.88); +} +.purchase-section--secondary .btn-price { + font-size: 26rpx; + font-weight: 500; +} + +/* 「购买本章」主按钮:与读书会同款渐变(默认双按钮时优先单章) */ +.purchase-section--primary { + background: linear-gradient(135deg, #00ced1 0%, #20b2aa 100%); + border: none; + box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.3); +} +.purchase-section--primary .btn-label, +.purchase-section--primary .btn-price { + color: #ffffff; +} +.purchase-section--primary .btn-price { + font-size: 36rpx; + font-weight: 700; +} + +/* 双按钮初期:读书会弱化(描边小一号) */ +.purchase-fullbook--secondary { + background: transparent; + border: 2rpx solid rgba(255, 255, 255, 0.22); + box-shadow: none; + padding: 22rpx 28rpx; +} +.purchase-fullbook--secondary .btn-label { + font-size: 26rpx; + color: rgba(255, 255, 255, 0.88); + font-weight: 500; +} +.purchase-fullbook--secondary .btn-price { + font-size: 28rpx; + font-weight: 600; + color: #00ced1; +} + +/* 凸显读书会时:大按钮在上 */ +.purchase-options--365-first .purchase-fullbook { + order: -1; +} +.purchase-options--365-first .purchase-section { + order: 1; +} .purchase-fullbook { background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); @@ -532,6 +768,12 @@ padding: 24rpx 0; } +/* 365 营销为主:收紧卡片、去掉顶锁视觉干扰 */ +.paywall--pay365-focus { + padding-top: 36rpx; + border: 2rpx solid rgba(0, 206, 209, 0.28); +} + /* ===== 章节导航 ===== */ .chapter-nav { margin-top: 96rpx; @@ -1448,6 +1690,76 @@ button.action-share-native { filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25)); } +/* ===== 分享到朋友圈 弹窗 ===== */ +.moments-modal { + padding: 40rpx; +} +.moments-earn-banner { + display: flex; + align-items: baseline; + justify-content: center; + gap: 6rpx; + margin-bottom: 36rpx; + padding: 28rpx; + background: linear-gradient(135deg, rgba(0, 206, 209, 0.12) 0%, rgba(255, 215, 0, 0.08) 100%); + border-radius: 20rpx; + border: 1rpx solid rgba(0, 206, 209, 0.2); +} +.moments-earn-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.75); +} +.moments-earn-pct { + font-size: 48rpx; + font-weight: 800; + color: #FFD700; + line-height: 1.1; +} +.moments-steps { + display: flex; + flex-direction: column; + gap: 24rpx; + margin-bottom: 36rpx; +} +.moments-step { + display: flex; + align-items: center; + gap: 20rpx; +} +.moments-step-num { + flex-shrink: 0; + width: 48rpx; + height: 48rpx; + border-radius: 50%; + background: rgba(0, 206, 209, 0.15); + color: #00CED1; + font-size: 26rpx; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} +.moments-step-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.7); + line-height: 1.45; +} +.moments-copy-btn { + width: 100%; + padding: 28rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); + color: #000; + font-size: 32rpx; + font-weight: 700; + text-align: center; +} +.moments-copy-btn--done { + background: rgba(0, 206, 209, 0.15); + border: 1rpx solid rgba(0, 206, 209, 0.45); + color: #00CED1; +} + /* 朋友圈单页:垂直向下箭头,贴近底栏「前往小程序」按钮区域 */ .singlepage-launch-pointer { position: fixed; @@ -1479,3 +1791,138 @@ button.action-share-native { transform: translateY(10rpx) scale(1.04); } } + +/* ===== 朋友圈单页:点「支付解锁全文」→ 遮罩 + 底栏提示 + 向下箭头(指向系统「前往小程序」) ===== */ +.moments-launch-guide-mask { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(6rpx); + z-index: 10070; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + box-sizing: border-box; +} + +.moments-launch-guide-sheet { + width: 100%; + max-width: 750rpx; + margin: 0 auto; + padding: 36rpx 40rpx 28rpx; + padding-bottom: calc(28rpx + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, #252528 0%, #1c1c1e 100%); + border-radius: 32rpx 32rpx 0 0; + border-top: 2rpx solid rgba(0, 206, 209, 0.22); + box-shadow: 0 -12rpx 48rpx rgba(0, 0, 0, 0.45); + box-sizing: border-box; +} + +.moments-launch-guide-text { + display: block; + font-size: 30rpx; + line-height: 1.55; + color: rgba(255, 255, 255, 0.92); + text-align: center; + padding: 0 16rpx; +} + +.moments-launch-guide-arrow-wrap { + display: flex; + justify-content: center; + align-items: flex-start; + margin-top: 12rpx; + padding-bottom: 8rpx; + min-height: 120rpx; +} + +.moments-launch-guide-arrow { + font-size: 96rpx; + line-height: 1; + color: #00ced1; + text-shadow: 0 0 24rpx rgba(0, 206, 209, 0.45); + animation: moments-launch-guide-arrow-bounce 0.85s ease-in-out infinite; +} + +@keyframes moments-launch-guide-arrow-bounce { + 0%, + 100% { + transform: translateY(0); + opacity: 0.88; + } + 50% { + transform: translateY(28rpx); + opacity: 1; + } +} + +/* 365 营销锚点:付费墙内一行,点击后再弹窗 */ +.pay365-anchor-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 16rpx; + margin: 0 0 24rpx; + padding: 24rpx 28rpx; + border-radius: 20rpx; + background: linear-gradient(135deg, rgba(0, 120, 115, 0.45) 0%, rgba(0, 206, 209, 0.22) 100%); + border: 2rpx solid rgba(0, 206, 209, 0.55); + box-sizing: border-box; + box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.25); +} +.pay365-anchor-row-badge { + flex-shrink: 0; + font-size: 22rpx; + font-weight: 700; + color: #062a2b; + background: linear-gradient(180deg, #9fffea 0%, #38bdac 100%); + padding: 6rpx 14rpx; + border-radius: 10rpx; + line-height: 1.2; +} +.pay365-anchor-row-text { + flex: 1; + font-size: 28rpx; + color: #e8fffc; + line-height: 1.45; + font-weight: 500; +} +.pay365-anchor-row-chevron { + font-size: 40rpx; + color: #9fffea; + font-weight: 300; + margin-left: auto; + line-height: 1; +} + +/* 已解锁全文时:底部固定锚点条 */ +.pay365-anchor-float { + position: fixed; + left: 24rpx; + right: 24rpx; + bottom: calc(24rpx + env(safe-area-inset-bottom)); + z-index: 96; + display: flex; + align-items: center; + justify-content: space-between; + padding: 22rpx 28rpx; + border-radius: 999rpx; + background: linear-gradient(90deg, rgba(15, 33, 55, 0.96), rgba(10, 22, 40, 0.96)); + border: 1rpx solid rgba(0, 206, 209, 0.45); + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.35); + box-sizing: border-box; +} +.pay365-anchor-float-text { + flex: 1; + font-size: 28rpx; + color: #e8fbfa; +} +.pay365-anchor-float-chevron { + font-size: 36rpx; + color: #38bdac; + margin-left: 12rpx; +} diff --git a/miniprogram/pages/referral/referral.js b/miniprogram/pages/referral/referral.js index 6acfa629..dff1cbc5 100644 --- a/miniprogram/pages/referral/referral.js +++ b/miniprogram/pages/referral/referral.js @@ -945,7 +945,7 @@ Page({ if (match) { return { bookTitle: match[1] || '未知书籍', - chapterTitle: match[2] || (productType === 'fullbook' ? '全书购买' : '') + chapterTitle: match[2] || (productType === 'fullbook' ? '加入读书会' : '') } } diff --git a/miniprogram/pages/referral/referral.json b/miniprogram/pages/referral/referral.json index 64f02792..068b4034 100644 --- a/miniprogram/pages/referral/referral.json +++ b/miniprogram/pages/referral/referral.json @@ -2,5 +2,7 @@ "usingComponents": {}, "navigationStyle": "custom", "enableShareAppMessage": true, - "enableShareTimeline": true + "enableShareTimeline": true, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" } diff --git a/miniprogram/pages/super-crm/super-crm.js b/miniprogram/pages/super-crm/super-crm.js new file mode 100644 index 00000000..d37df969 --- /dev/null +++ b/miniprogram/pages/super-crm/super-crm.js @@ -0,0 +1,179 @@ +const app = getApp() +const { trackClick } = require('../../utils/trackClick') + +function formatDateTime(iso) { + if (!iso) return '--' + const d = new Date(iso) + if (isNaN(d.getTime())) return '--' + const m = (d.getMonth() + 1).toString().padStart(2, '0') + const day = d.getDate().toString().padStart(2, '0') + const h = d.getHours().toString().padStart(2, '0') + const min = d.getMinutes().toString().padStart(2, '0') + return `${m}-${day} ${h}:${min}` +} + +function shortUserId(id) { + const s = String(id || '').trim() + if (!s) return '' + return s.length > 14 ? `${s.slice(0, 8)}…` : s +} + +Page({ + data: { + statusBarHeight: 44, + tabIndex: 0, + personName: '', + leads: [], + leadPage: 1, + leadHasMore: true, + leadLoading: false, + tracks: [], + trackLoading: false, + trackHasMore: true, + trackBefore: '', + }, + + onLoad() { + this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 }) + this._ensureSuperThenLoad() + }, + + async _ensureSuperThenLoad() { + const userId = app.globalData.userInfo?.id + if (!app.globalData.isLoggedIn || !userId) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + return + } + try { + const res = await app.request({ + url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`, + silent: true, + }) + const ok = res?.success && res.data?.isSuperIndividual === true + if (!ok) { + wx.showToast({ title: '仅超级个体可查看', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + return + } + } catch (_) { + wx.showToast({ title: '无法验证身份', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + return + } + this.loadLeads(true) + }, + + goBack() { + wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) }) + }, + + switchTab(e) { + const idx = Number(e.currentTarget.dataset.index) || 0 + if (idx === this.data.tabIndex) return + trackClick('super_crm', 'tab_click', idx === 0 ? '客资' : '轨迹') + this.setData({ tabIndex: idx }) + if (idx === 0 && this.data.leads.length === 0) { + this.loadLeads(true) + } + if (idx === 1 && this.data.tracks.length === 0) { + this.loadTracks(true) + } + }, + + async loadLeads(reset) { + const userId = app.globalData.userInfo?.id + if (!userId) return + const page = reset ? 1 : this.data.leadPage + if (!reset && !this.data.leadHasMore) return + this.setData({ leadLoading: true }) + try { + const res = await app.request({ + url: `/api/miniprogram/my/super-leads?userId=${encodeURIComponent(userId)}&page=${page}&pageSize=20`, + silent: true, + }) + if (res?.forbidden || (res && res.success === false && String(res.error || '').indexOf('超级个体') >= 0)) { + wx.showToast({ title: '仅超级个体可查看', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + return + } + if (!res?.success) { + this.setData({ leadLoading: false }) + return + } + const raw = Array.isArray(res.records) ? res.records : [] + const mapped = raw.map((r) => ({ + ...r, + createdAtText: formatDateTime(r.createdAt), + })) + const total = Number(res.total || 0) + const nextList = reset ? mapped : (this.data.leads || []).concat(mapped) + const hasMore = nextList.length < total + this.setData({ + personName: res.personName || this.data.personName, + leads: nextList, + leadPage: page + 1, + leadHasMore: hasMore, + leadLoading: false, + }) + } catch (e) { + this.setData({ leadLoading: false }) + wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' }) + } + }, + + async loadTracks(reset) { + const userId = app.globalData.userInfo?.id + if (!userId) return + if (!reset && !this.data.trackHasMore) return + const before = reset ? '' : this.data.trackBefore + if (!reset && !before) { + this.setData({ trackHasMore: false }) + return + } + this.setData({ trackLoading: true }) + try { + let url = `/api/miniprogram/my/super-lead-tracks?userId=${encodeURIComponent(userId)}&limit=40` + if (before) url += `&before=${encodeURIComponent(before)}` + const res = await app.request({ url, silent: true }) + if (res?.forbidden || (res && res.success === false && String(res.error || '').indexOf('超级个体') >= 0)) { + wx.showToast({ title: '仅超级个体可查看', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + return + } + if (!res?.success) { + this.setData({ trackLoading: false }) + return + } + const raw = Array.isArray(res.tracks) ? res.tracks : [] + const mapped = raw.map((t) => ({ + ...t, + userShort: shortUserId(t.userId), + createdAtText: formatDateTime(t.createdAt), + })) + const nextList = reset ? mapped : (this.data.tracks || []).concat(mapped) + const nextBefore = res.nextBefore || '' + const hasMore = !!(nextBefore && mapped.length > 0) + this.setData({ + personName: res.personName || this.data.personName, + tracks: nextList, + trackBefore: nextBefore, + trackHasMore: hasMore, + trackLoading: false, + }) + } catch (e) { + this.setData({ trackLoading: false }) + wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' }) + } + }, + + onLeadReachBottom() { + if (this.data.tabIndex !== 0) return + this.loadLeads(false) + }, + + onTrackReachBottom() { + if (this.data.tabIndex !== 1) return + this.loadTracks(false) + }, +}) diff --git a/miniprogram/pages/super-crm/super-crm.json b/miniprogram/pages/super-crm/super-crm.json new file mode 100644 index 00000000..15a95482 --- /dev/null +++ b/miniprogram/pages/super-crm/super-crm.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "链接客资", + "navigationStyle": "custom" +} diff --git a/miniprogram/pages/super-crm/super-crm.wxml b/miniprogram/pages/super-crm/super-crm.wxml new file mode 100644 index 00000000..5af1ac69 --- /dev/null +++ b/miniprogram/pages/super-crm/super-crm.wxml @@ -0,0 +1,71 @@ ++ - -- @@ -27,129 +27,142 @@ - - -- - + +- -- - -{{chaptersBookTitle}} -{{chaptersBookSubtitle}} -- -- -{{totalSections}} -章节 -{{bookCollapsed ? '展开 ▸' : '折叠 ▾'}} -- - + + +- - - - - -- -- 序言|为什么我每天早上6点在Soul开播? -- -免费 -- - - -- - @@ -159,38 +159,44 @@- + +- - {{item.iconText}} -{{item.iconEmoji}} -{{item.title[0] || '篇'}} -- + +{{item.title}} -{{item.subtitle}} -+ + diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss index 3c8ca0fc..4ac27317 100644 --- a/miniprogram/pages/chapters/chapters.wxss +++ b/miniprogram/pages/chapters/chapters.wxss @@ -1,6 +1,6 @@ /** * Soul创业实验 - 目录页样式 - * 1:1还原Web版本UI + * 三书同级卡片 + 折叠/展开 */ .page { @@ -45,7 +45,6 @@ text-align: center; } -/* 搜索按钮 */ .search-btn { width: 64rpx; height: 64rpx; @@ -67,33 +66,31 @@ color: rgba(255, 255, 255, 0.6); } -.brand-color { - color: #00CED1; -} +.brand-color { color: #00CED1; } -.nav-placeholder { - width: 100%; -} +.nav-placeholder { width: 100%; } /* ===== 目录骨架屏 ===== */ .parts-skeleton { - padding: 32rpx; + padding: 24rpx 32rpx; + display: flex; + flex-direction: column; + gap: 24rpx; } .skeleton-book-card { display: flex; align-items: center; gap: 24rpx; - padding: 32rpx; + padding: 36rpx 32rpx; background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); - border-radius: 32rpx; - margin-bottom: 32rpx; + border-radius: 24rpx; } .skeleton-book-icon { - width: 96rpx; - height: 96rpx; - border-radius: 24rpx; + width: 88rpx; + height: 88rpx; + border-radius: 22rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease-in-out infinite; @@ -108,7 +105,7 @@ } .skeleton-line { - height: 32rpx; + height: 28rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease-in-out infinite; @@ -119,26 +116,12 @@ .skeleton-subtitle { width: 50%; } .skeleton-count { - width: 80rpx; - height: 64rpx; + width: 72rpx; + height: 56rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease-in-out infinite; - border-radius: 16rpx; -} - -.skeleton-part-list { - display: flex; - flex-direction: column; - gap: 24rpx; -} - -.skeleton-part-item .skeleton-part-header { - height: 100rpx; - background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); - background-size: 200% 100%; - animation: skeleton-shimmer 1.5s ease-in-out infinite; - border-radius: 16rpx; + border-radius: 12rpx; } @keyframes skeleton-shimmer { @@ -146,109 +129,191 @@ 100% { background-position: -200% 0; } } -/* ===== 书籍信息卡 ===== */ -.book-info-card { - display: flex; - align-items: center; - gap: 24rpx; - margin: 32rpx 32rpx 24rpx 32rpx; - padding: 32rpx; -} - -.card-gradient { - background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); - border-radius: 32rpx; - border: 2rpx solid rgba(0, 206, 209, 0.2); - box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.2); -} - -.book-icon { - width: 96rpx; - height: 96rpx; - border-radius: 24rpx; - background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.book-icon-inner { - font-size: 48rpx; -} - -.book-info { - flex: 1; - min-width: 0; -} - -.book-title { - font-size: 32rpx; - font-weight: 600; - color: #ffffff; - display: block; - margin-bottom: 4rpx; -} - -.book-subtitle { - font-size: 22rpx; - color: rgba(255, 255, 255, 0.4); -} - -.book-right-area { display: flex; flex-direction: column; align-items: flex-end; gap: 8rpx; } -.book-count { text-align: right; } -.count-value { font-size: 40rpx; font-weight: 700; display: block; } -.count-label { font-size: 20rpx; color: rgba(255, 255, 255, 0.4); } -.book-collapse-hint { font-size: 20rpx; color: #00CED1; opacity: 0.7; } - -/* ===== 目录内容 ===== */ +/* ===== 目录内容区 ===== */ .chapters-content { - padding: 0 32rpx; + padding: 16rpx 24rpx; width: 100%; box-sizing: border-box; } -/* ===== 章节项 ===== */ +/* ===== 书组 ===== */ +.book-group { + margin-bottom: 20rpx; + border-radius: 28rpx; + overflow: hidden; + transition: all 0.3s ease; +} + +.book-group--expanded { + background: rgba(255, 255, 255, 0.02); + border: 2rpx solid rgba(0, 206, 209, 0.12); + padding-bottom: 8rpx; +} + +/* ===== 书卡片 ===== */ +.book-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx 28rpx; + background: linear-gradient(135deg, #141416 0%, #1e2024 50%, #1a1c20 100%); + border-radius: 28rpx; + border: 2rpx solid rgba(0, 206, 209, 0.15); + position: relative; + overflow: hidden; +} + +.book-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2rpx; + background: linear-gradient(90deg, transparent, rgba(0, 206, 209, 0.3), transparent); +} + +.book-card:active { + background: linear-gradient(135deg, #1a1c1e 0%, #252830 50%, #202428 100%); +} + +.book-card-left { + display: flex; + align-items: center; + gap: 20rpx; + flex: 1; + min-width: 0; +} + +.book-card-icon { + width: 88rpx; + height: 88rpx; + border-radius: 22rpx; + background: linear-gradient(135deg, #00CED1 0%, #008B8B 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 6rpx 20rpx rgba(0, 206, 209, 0.25); +} + +.book-card-emoji { + font-size: 44rpx; + line-height: 1; +} + +.book-card-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.book-card-title { + font-size: 30rpx; + font-weight: 700; + color: #ffffff; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 1rpx; +} + +.book-card-subtitle { + font-size: 22rpx; + color: rgba(255, 255, 255, 0.35); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-card-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6rpx; + flex-shrink: 0; + margin-left: 16rpx; +} + +.book-card-stats { + display: flex; + align-items: baseline; + gap: 4rpx; +} + +.book-card-count { + font-size: 40rpx; + font-weight: 800; + color: #00CED1; + line-height: 1.1; + letter-spacing: -1rpx; +} + +.book-card-unit { + font-size: 20rpx; + color: rgba(255, 255, 255, 0.35); + margin-left: 2rpx; +} + +.book-card-toggle { + font-size: 20rpx; + color: rgba(0, 206, 209, 0.6); + transition: color 0.2s ease; +} + +.toggle-active { + color: #00CED1; +} + +/* ===== 章节项(序言/尾声) ===== */ .chapter-item { display: flex; align-items: center; justify-content: space-between; - padding: 24rpx; - background: #1c1c1e; - border-radius: 24rpx; - border: 2rpx solid rgba(255, 255, 255, 0.05); - margin-bottom: 24rpx; + padding: 22rpx 28rpx; + margin: 0 12rpx 16rpx 12rpx; + background: rgba(28, 28, 30, 0.7); + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.04); } .chapter-item:active { - background: #2c2c2e; + background: rgba(44, 44, 46, 0.8); } .item-left { display: flex; align-items: center; - gap: 24rpx; + gap: 20rpx; flex: 1; min-width: 0; } .item-icon { - width: 64rpx; - height: 64rpx; - border-radius: 16rpx; + width: 56rpx; + height: 56rpx; + border-radius: 14rpx; display: flex; align-items: center; justify-content: center; - font-size: 32rpx; flex-shrink: 0; } +.item-emoji { + font-size: 28rpx; + line-height: 1; +} + .icon-brand { - background: rgba(0, 206, 209, 0.2); + background: rgba(0, 206, 209, 0.12); } .item-title { - font-size: 28rpx; + font-size: 26rpx; font-weight: 500; color: #ffffff; overflow: hidden; @@ -259,12 +324,12 @@ .item-right { display: flex; align-items: center; - gap: 16rpx; + gap: 12rpx; flex-shrink: 0; } .item-arrow { - font-size: 32rpx; + font-size: 28rpx; color: rgba(255, 255, 255, 0.4); } @@ -273,9 +338,9 @@ display: inline-flex; align-items: center; justify-content: center; - font-size: 22rpx; - padding: 6rpx 16rpx; - min-width: 80rpx; + font-size: 20rpx; + padding: 4rpx 14rpx; + min-width: 64rpx; border-radius: 8rpx; box-sizing: border-box; text-align: center; @@ -289,7 +354,7 @@ .tag-new { background: rgba(0, 206, 209, 0.15); color: #00CED1; - font-size: 20rpx; + font-size: 18rpx; padding: 2rpx 8rpx; margin-left: 8rpx; } @@ -297,153 +362,134 @@ .tag-vip { background: rgba(255, 215, 0, 0.12); color: #FFD700; - font-size: 20rpx; + font-size: 18rpx; padding: 2rpx 8rpx; margin-left: 8rpx; } -.text-brand { - color: #00CED1; -} - -.text-muted { - color: rgba(255, 255, 255, 0.4); -} - -.text-xs { - font-size: 22rpx; -} +.text-brand { color: #00CED1; } +.text-muted { color: rgba(255, 255, 255, 0.4); } +.text-xs { font-size: 22rpx; } /* ===== 篇章列表 ===== */ .part-list { - margin-bottom: 24rpx; + margin: 0 12rpx 16rpx; } .part-item { - margin-bottom: 24rpx; + margin-bottom: 12rpx; } .part-header { display: flex; align-items: center; justify-content: space-between; - padding: 24rpx; - background: #1c1c1e; - border-radius: 24rpx; - border: 2rpx solid rgba(255, 255, 255, 0.05); + padding: 22rpx 24rpx; + background: rgba(28, 28, 30, 0.7); + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.04); } .part-header:active { - background: #2c2c2e; + background: rgba(44, 44, 46, 0.8); } .part-left { display: flex; align-items: center; - gap: 24rpx; + gap: 20rpx; + flex: 1; + min-width: 0; } .part-icon { - width: 64rpx; - height: 64rpx; - border-radius: 16rpx; - background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); + width: 56rpx; + height: 56rpx; + border-radius: 14rpx; + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.2); display: flex; align-items: center; justify-content: center; - font-size: 28rpx; + font-size: 24rpx; font-weight: 700; color: #ffffff; flex-shrink: 0; } -/* 与管理端 ChapterTree 篇头 emoji 一致 */ + .part-icon-emoji { - font-size: 34rpx; + font-size: 28rpx; font-weight: 400; line-height: 1; - color: #ffffff; - background: linear-gradient(135deg, #1e3a4a 0%, #0f172a 100%); - border: 2rpx solid rgba(0, 206, 209, 0.35); + background: linear-gradient(135deg, #1a2e3e 0%, #0d1b2a 100%); + border: 1rpx solid rgba(0, 206, 209, 0.25); } + .part-icon-img { - width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0; + width: 56rpx; + height: 56rpx; + border-radius: 14rpx; + flex-shrink: 0; } .part-info { display: flex; flex-direction: column; + min-width: 0; + flex: 1; } .part-title { - font-size: 28rpx; + font-size: 27rpx; font-weight: 600; color: #ffffff; -} - -.part-icon-text { - font-size: 22rpx; - font-weight: 700; - letter-spacing: 1rpx; - line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .part-subtitle { font-size: 20rpx; - color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.35); margin-top: 4rpx; } .part-right { display: flex; align-items: center; - gap: 16rpx; + gap: 12rpx; + flex-shrink: 0; } .part-count { font-size: 22rpx; - color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.35); } .part-arrow { - font-size: 32rpx; + font-size: 28rpx; color: rgba(255, 255, 255, 0.4); transition: transform 0.3s ease; } -.arrow-down { - transform: rotate(90deg); -} - /* ===== 章节组 ===== */ .chapters-list { - margin-top: 16rpx; - margin-left: 16rpx; + margin-top: 12rpx; + margin-left: 12rpx; } .chapters-loading { padding: 24rpx; - font-size: 26rpx; + font-size: 24rpx; color: rgba(255, 255, 255, 0.5); } -.chapter-group { - background: rgba(28, 28, 30, 0.5); - border-radius: 16rpx; - border: 2rpx solid rgba(255, 255, 255, 0.05); - overflow: hidden; - margin-bottom: 8rpx; -} - .chapter-header { - padding: 16rpx 24rpx; - font-size: 24rpx; + padding: 14rpx 24rpx; + font-size: 23rpx; font-weight: 500; - color: rgba(255, 255, 255, 0.6); - border-bottom: 1rpx solid rgba(255, 255, 255, 0.05); -} - -.section-list { - /* 小节列表 */ + color: rgba(255, 255, 255, 0.5); + border-bottom: 1rpx solid rgba(255, 255, 255, 0.04); } .section-item { @@ -451,55 +497,46 @@ flex-direction: row; align-items: center; justify-content: space-between; - padding: 20rpx 24rpx; - border-bottom: 1rpx solid rgba(255, 255, 255, 0.05); + padding: 18rpx 24rpx; + border-bottom: 1rpx solid rgba(255, 255, 255, 0.04); } -.section-item:last-child { - border-bottom: none; -} +.section-item:last-child { border-bottom: none; } .section-item:active { - background: rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.04); } .section-left { display: flex; flex-direction: row; align-items: center; - gap: 16rpx; + gap: 14rpx; flex: 1; min-width: 0; } -/* 小节锁图标 */ .section-lock { - width: 32rpx; - min-width: 32rpx; - font-size: 24rpx; + width: 28rpx; + min-width: 28rpx; + font-size: 22rpx; text-align: center; flex-shrink: 0; } .section-lock-wrap { - min-width: 32rpx; + min-width: 28rpx; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } -.lock-open { - color: #00CED1; -} +.lock-open { color: #00CED1; } +.lock-closed { color: rgba(255, 255, 255, 0.3); } -.lock-closed { - color: rgba(255, 255, 255, 0.3); -} - -/* 小节标题 */ .section-title { - font-size: 26rpx; + font-size: 25rpx; color: #ffffff; overflow: hidden; text-overflow: ellipsis; @@ -507,13 +544,11 @@ flex: 1; } -/* 小节价格 */ .section-price { - font-size: 24rpx; - color: rgba(255, 255, 255, 0.5); + font-size: 22rpx; + color: rgba(255, 255, 255, 0.45); } -/* 已购标签 */ .tag-purchased { background: rgba(0, 206, 209, 0.15); color: #00CED1; @@ -522,68 +557,56 @@ .section-right { display: flex; align-items: center; - gap: 16rpx; + gap: 12rpx; flex-shrink: 0; - margin-left: 16rpx; + margin-left: 12rpx; } .section-arrow { - font-size: 28rpx; + font-size: 24rpx; color: rgba(255, 255, 255, 0.3); } /* ===== 附录 ===== */ .card { - background: #1c1c1e; - border-radius: 24rpx; - border: 2rpx solid rgba(255, 255, 255, 0.05); - margin: 0 0 24rpx 0; - width: 100%; + background: rgba(28, 28, 30, 0.7); + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.04); + margin: 0 12rpx 16rpx 12rpx; box-sizing: border-box; } .appendix-card { - padding: 24rpx; - width: 100%; - box-sizing: border-box; - margin: 0 0 24rpx 0; + padding: 20rpx 24rpx; } .appendix-title { - font-size: 24rpx; + font-size: 23rpx; font-weight: 500; - color: rgba(255, 255, 255, 0.6); + color: rgba(255, 255, 255, 0.5); display: block; - margin-bottom: 16rpx; -} - -.appendix-list { - /* 附录列表 */ + margin-bottom: 12rpx; } .appendix-item { display: flex; align-items: center; justify-content: space-between; - padding: 16rpx 0; - border-bottom: 1rpx solid rgba(255, 255, 255, 0.05); + padding: 14rpx 0; + border-bottom: 1rpx solid rgba(255, 255, 255, 0.04); } -.appendix-item:last-child { - border-bottom: none; -} +.appendix-item:last-child { border-bottom: none; } -.appendix-item:active { - opacity: 0.7; -} +.appendix-item:active { opacity: 0.7; } .appendix-text { - font-size: 24rpx; - color: rgba(255, 255, 255, 0.8); + font-size: 23rpx; + color: rgba(255, 255, 255, 0.7); } .appendix-arrow { - font-size: 28rpx; + font-size: 24rpx; color: rgba(255, 255, 255, 0.3); } @@ -592,15 +615,15 @@ display: flex; align-items: center; justify-content: center; - padding: 20rpx 0 12rpx; + padding: 16rpx 0 10rpx; } .latest-expand-triangle { width: 0; height: 0; - border-left: 18rpx solid transparent; - border-right: 18rpx solid transparent; - border-top: 14rpx solid rgba(0, 206, 209, 0.55); + border-left: 16rpx solid transparent; + border-right: 16rpx solid transparent; + border-top: 12rpx solid rgba(0, 206, 209, 0.5); opacity: 0.85; transform-origin: 50% 0; transition: border-top-color 0.15s ease; @@ -611,19 +634,9 @@ } @keyframes catalog-tri-nudge { - 0% { - transform: translateY(0) scale(1); - opacity: 0.85; - } - 40% { - transform: translateY(10rpx) scale(1.12); - opacity: 1; - border-top-color: #00CED1; - } - 100% { - transform: translateY(0) scale(1); - opacity: 0.85; - } + 0% { transform: translateY(0) scale(1); opacity: 0.85; } + 40% { transform: translateY(10rpx) scale(1.12); opacity: 1; border-top-color: #00CED1; } + 100% { transform: translateY(0) scale(1); opacity: 0.85; } } .latest-expand-triangle.tri-bounce { diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 990cd2fc..73ec0f69 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -39,6 +39,17 @@ function superAvatarLetter(displayName) { return /[\u4e00-\u9fff]/.test(ch) ? ch : '会' } +/** 旧 OSS 存 16personalities .svg 时改为 png 目录,与后端 resolveAvatarURL 一致 */ +function normalizeLegacyMbtiAvatarUrl(u) { + if (!u || typeof u !== 'string') return u + const lower = u.toLowerCase() + if (!lower.includes('mbti-avatar')) return u + let s = u.replace(/mbti-avatars-16personalities-male/g, 'mbti-avatars-png-male') + s = s.replace(/mbti-avatars-16personalities-female/g, 'mbti-avatars-png-female') + if (/\.svg$/i.test(s)) s = s.replace(/\.svg$/i, '.png') + return s +} + function parseTimeToMinutes(t) { const m = String(t || '').trim().match(/^(\d{1,2}):(\d{2})$/) if (!m) return null @@ -196,6 +207,10 @@ Page({ superMembers: [], superMembersLoading: true, + // 收益排行榜 + earningsBoard: [], + earningsBoardEnabled: false, + // 最新新增章节(完整列表 + 展示列表,用于展开/折叠) latestChapters: [], displayLatestChapters: [], @@ -298,6 +313,10 @@ Page({ this.setData({ auditMode: app.globalData.auditMode || false }) this._refreshHomeEntryFlags() void this.loadHomePinnedPerson() + // 首页 tab 再次展示时重拉超级个体(避免首进网络失败或仅 onLoad 拉一次后长期空白) + if (!app.globalData.auditMode) { + void this.loadSuperMembers() + } // 设置TabBar选中状态 if (typeof this.getTabBar === 'function' && this.getTabBar()) { @@ -330,6 +349,7 @@ Page({ this.loadBookData() this.loadFeaturedAndLatest() this.loadSuperMembers() + this.loadEarningsBoard() }, async loadSuperMembers() { @@ -359,6 +379,26 @@ Page({ } }, + async loadEarningsBoard() { + try { + const res = await app.request({ url: '/api/miniprogram/earnings/leaderboard', silent: true }).catch(() => null) + if (res && res.success && res.enabled && Array.isArray(res.data) && res.data.length > 0) { + const board = res.data.map(item => ({ + rank: item.rank, + nickname: item.nickname || '分享达人', + avatar: normalizeLegacyMbtiAvatarUrl(item.avatar || ''), + avatarLetter: superAvatarLetter(item.nickname || '创') + })) + this.setData({ earningsBoard: board, earningsBoardEnabled: true }) + console.log('[Index] 收益排行榜:', board.length, '人') + } else { + this.setData({ earningsBoardEnabled: res && res.enabled === false ? false : true }) + } + } catch (e) { + console.log('[Index] 加载收益排行榜失败:', e) + } + }, + // 精选推荐 + 最新更新 + 最新列表:顺序以后端为准(recommended=排行榜算法,latest=updated_at) async loadFeaturedAndLatest() { this.setData({ bannerLoading: true }) diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 7ba67e96..617794e5 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -137,6 +137,35 @@+ - - -+ + + ++ -{{book.iconEmoji || '📖'}} - +{{item.chapters.length || item.chapterCount}}章 -+ + +{{book.title}} +{{book.subtitle}} ++ ++ +{{book.sectionCount || totalSections}} +章节 +{{expandedBookId === book.id ? '收起 ▴' : '展开 ▾'}} ++ + + + - -+ ++ +📜 +序言|为什么我每天早上6点在Soul开播? ++ 免费 +- - -加载中... -- -{{chapter.title}} -- -- - - - - + + 加载中... + + ++ + + + + + ++ ++ {{item.iconEmoji}} +{{item.title[0] || '篇'}} ++ +{{item.title}} +{{item.subtitle}} ++ +{{item.chapters.length || item.chapterCount}}章 ++ + -+ 加载中... ++ +{{chapter.title}} ++ -+ + -+ ++ ++ + {{section.id}} {{section.title}} +{{section.newBadgeText || chaptersNewBadgeText}} ++ 免费 +¥{{section.price}} ++ {{section.id}} {{section.title}} -{{section.newBadgeText || chaptersNewBadgeText}} -- 免费 -¥{{section.price}} -+ + - - - - - -- -- 尾声|这本书的真实目的 -- -免费 -- - +附录 -- + + +- -{{item.title}} -+ + + ++ ++ +🏁 +尾声|这本书的真实目的 ++ +免费 ++ + + +📎 附录 ++ ++ +{{item.title}} ++ + ++ +收益排行榜 ++ ++ ++ ++ ++ +{{item.rank}} ++ ++ {{item.avatarLetter}} +{{item.nickname}} +diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 6e0d346d..cd808011 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -729,6 +729,114 @@ color: #00CED1; } +/* ===== 收益排行榜(纵向列表样式预留,当前首页用 super-scroll 横滑) ===== */ +.earnings-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} +.earnings-item { + display: flex; + align-items: center; + gap: 20rpx; + padding: 20rpx 24rpx; + background: rgba(255,255,255,0.04); + border-radius: 16rpx; + transition: background 0.15s; +} +.earnings-item-hover { + background: rgba(255,255,255,0.08); +} +.earnings-rank { + width: 44rpx; + height: 44rpx; + border-radius: 50%; + background: rgba(255,255,255,0.08); + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + font-weight: 700; + color: rgba(255,255,255,0.5); + flex-shrink: 0; +} +.earnings-rank-top { + background: linear-gradient(135deg, #FFD700, #FFA500); + color: #1a1a2e; +} +.earnings-avatar { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + overflow: hidden; + background: rgba(0,206,209,0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 2rpx solid rgba(255,215,0,0.3); +} +.earnings-avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} +.earnings-avatar-text { + font-size: 28rpx; + font-weight: 600; + color: #00CED1; +} +.earnings-name { + flex: 1; + font-size: 26rpx; + color: rgba(255,255,255,0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.earnings-amount { + font-size: 28rpx; + font-weight: 700; + color: #FFD700; + flex-shrink: 0; +} + +/* 收益排行榜横滑:与 .super-scroll 共用;名次标叠在头像上 */ +.super-scroll .share-avatar-wrap { + width: 112rpx; +} + +.share-avatar-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.share-avatar-wrap .share-rank { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.6); + font-size: 22rpx; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: -6rpx; + left: 50%; + transform: translateX(-50%); + z-index: 2; +} + +.share-rank-top { + background: linear-gradient(135deg, #FFD700, #FFA500); + color: #1a1a2e; +} + /* ===== 最新新增(时间线样式) ===== */ .latest-header { margin-bottom: 32rpx; diff --git a/miniprogram/pages/match-history/match-history.js b/miniprogram/pages/match-history/match-history.js new file mode 100644 index 00000000..701fb9c7 --- /dev/null +++ b/miniprogram/pages/match-history/match-history.js @@ -0,0 +1,80 @@ +const app = getApp() + +function formatTime(dateStr) { + if (!dateStr) return '' + const d = new Date(dateStr) + if (isNaN(d.getTime())) return '' + const now = new Date() + const diffMs = now - d + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return '刚刚' + if (diffMin < 60) return diffMin + '分钟前' + const diffH = Math.floor(diffMin / 60) + if (diffH < 24) return diffH + '小时前' + const diffD = Math.floor(diffH / 24) + if (diffD < 7) return diffD + '天前' + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return mm + '-' + dd +} + +Page({ + data: { + records: [], + loading: false, + noMore: false, + page: 1, + pageSize: 20, + }, + + onLoad() { + this.loadData() + }, + + onReachBottom() { + if (!this.data.loading && !this.data.noMore) { + this.loadData() + } + }, + + onPullDownRefresh() { + this.setData({ records: [], page: 1, noMore: false }) + this.loadData().then(() => wx.stopPullDownRefresh()) + }, + + async loadData() { + const uid = app.globalData.userInfo?.id + if (!uid) { + this.setData({ loading: false }) + return + } + this.setData({ loading: true }) + try { + const res = await app.request({ + url: `/api/miniprogram/match/history?userId=${uid}&page=${this.data.page}&pageSize=${this.data.pageSize}`, + silent: true, + }) + if (res && res.success && Array.isArray(res.data)) { + const items = res.data.map(item => ({ + ...item, + timeText: formatTime(item.createdAt), + })) + const merged = this.data.records.concat(items) + this.setData({ + records: merged, + page: this.data.page + 1, + noMore: items.length < this.data.pageSize, + }) + } else { + this.setData({ noMore: true }) + } + } catch (e) { + console.log('[MatchHistory] load error:', e) + } + this.setData({ loading: false }) + }, + + goToMatch() { + wx.switchTab({ url: '/pages/match/match' }) + }, +}) diff --git a/miniprogram/pages/match-history/match-history.json b/miniprogram/pages/match-history/match-history.json new file mode 100644 index 00000000..b491a261 --- /dev/null +++ b/miniprogram/pages/match-history/match-history.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "匹配记录", + "navigationBarBackgroundColor": "#0a0a1a", + "navigationBarTextStyle": "white", + "backgroundColor": "#0a0a1a", + "usingComponents": {} +} diff --git a/miniprogram/pages/match-history/match-history.wxss b/miniprogram/pages/match-history/match-history.wxss new file mode 100644 index 00000000..826e2b19 --- /dev/null +++ b/miniprogram/pages/match-history/match-history.wxss @@ -0,0 +1,128 @@ +.page { + min-height: 100vh; + background: #0a0a1a; + padding: 24rpx 32rpx; +} +.list { + display: flex; + flex-direction: column; + gap: 20rpx; +} +.record-item { + display: flex; + align-items: center; + gap: 20rpx; + padding: 28rpx 24rpx; + background: rgba(255,255,255,0.04); + border-radius: 20rpx; + border: 1rpx solid rgba(255,255,255,0.06); +} +.record-left { + flex-shrink: 0; +} +.record-avatar { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + overflow: hidden; + background: rgba(0,206,209,0.1); + display: flex; + align-items: center; + justify-content: center; + border: 2rpx solid rgba(255,255,255,0.1); +} +.avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} +.avatar-letter { + font-size: 36rpx; + font-weight: 600; + color: #00CED1; +} +.record-center { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8rpx; +} +.record-name { + font-size: 28rpx; + font-weight: 600; + color: rgba(255,255,255,0.9); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.record-meta { + display: flex; + align-items: center; + gap: 16rpx; +} +.record-type { + font-size: 22rpx; + color: #00CED1; + background: rgba(0,206,209,0.1); + padding: 4rpx 14rpx; + border-radius: 12rpx; +} +.record-time { + font-size: 22rpx; + color: rgba(255,255,255,0.35); +} +.record-contact { + display: flex; + gap: 16rpx; + margin-top: 4rpx; +} +.contact-tag { + font-size: 20rpx; + color: rgba(255,255,255,0.45); +} +.record-right { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4rpx; +} +.score-value { + font-size: 32rpx; + font-weight: 700; + color: #FFD700; +} +.score-label { + font-size: 20rpx; + color: rgba(255,255,255,0.35); +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 200rpx; + gap: 24rpx; +} +.empty-icon { + font-size: 80rpx; +} +.empty-text { + font-size: 28rpx; + color: rgba(255,255,255,0.4); +} +.empty-btn { + margin-top: 16rpx; + padding: 16rpx 48rpx; + background: rgba(0,206,209,0.15); + color: #00CED1; + border-radius: 40rpx; + font-size: 28rpx; +} +.loading-more, .no-more { + text-align: center; + padding: 40rpx; + font-size: 24rpx; + color: rgba(255,255,255,0.3); +} diff --git a/miniprogram/pages/match/match.wxss b/miniprogram/pages/match/match.wxss index af7e85ef..6af2b651 100644 --- a/miniprogram/pages/match/match.wxss +++ b/miniprogram/pages/match/match.wxss @@ -604,6 +604,19 @@ border-radius: 24rpx; } +.btn-gold-outline { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + padding: 28rpx; + background: transparent; + color: #FFD700; + font-size: 30rpx; + text-align: center; + border-radius: 24rpx; + border: 2rpx solid rgba(255, 215, 0, 0.4); +} .btn-secondary { padding: 32rpx; background: #1c1c1e; @@ -616,6 +629,32 @@ .btn-disabled { opacity: 0.5; + pointer-events: none; +} + +.btn-phone { + background: linear-gradient(135deg, #34C759, #28a745); +} + +.btn-icon { + margin-right: 8rpx; + vertical-align: middle; +} + +.match-mbti { + font-size: 22rpx; + color: #00CED1; + background: rgba(0, 206, 209, 0.1); + padding: 4rpx 12rpx; + border-radius: 8rpx; + display: inline-block; + margin-top: 6rpx; +} + +.interest-emoji { + font-size: 32rpx; + margin-right: 8rpx; + flex-shrink: 0; } /* ===== 弹窗 ===== */ diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index b6088f14..5bc732fd 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -86,6 +86,9 @@ Page({ // 我的余额 walletBalanceText: '--', + // 超级个体:快捷入口「客资 / 链接与轨迹」 + showSuperCrmEntry: false, + // mp_config.mpUi.myPage(后台可改文案/跳转) mpUiCardLabel: '名片', mpUiVipLabelVip: '会员中心', @@ -213,9 +216,11 @@ Page({ this.loadMyEarnings() this.loadPendingConfirm() this.loadVipStatus() + this.loadSuperCrmEntry() this.loadWalletBalance() this._refreshMyAvatarDisplay(safeUser) } else { + this.setData({ showSuperCrmEntry: false }) const guestReadCount = app.getReadCount() const guestRecent = this._mergeRecentChaptersFromLocal([]) this.setData({ @@ -900,6 +905,7 @@ Page({ referral: '/pages/referral/referral', withdrawRecords: '/pages/withdraw-records/withdraw-records', wallet: '/pages/wallet/wallet', + superCrm: '/pages/super-crm/super-crm', settings: '/pages/settings/settings' } @@ -953,6 +959,11 @@ Page({ wx.switchTab({ url: '/pages/match/match' }) }, + goToMatchHistory() { + trackClick('my', 'nav_click', '匹配记录') + wx.navigateTo({ url: '/pages/match-history/match-history' }) + }, + // 跳转到推广中心(需登录) goToReferral(e) { const focus = e && e.currentTarget && e.currentTarget.dataset ? (e.currentTarget.dataset.focus || '') : '' @@ -982,7 +993,25 @@ Page({ }) }, - // VIP状态查询(注意:hasFullBook=9.9 买断,不等同 VIP) + // VIP状态查询(注意:hasFullBook=365读书会权益,不等同 VIP) + async loadSuperCrmEntry() { + const userId = app.globalData.userInfo?.id + if (!userId) { + this.setData({ showSuperCrmEntry: false }) + return + } + try { + const res = await app.request({ + url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`, + silent: true + }) + const show = !!(res && res.success && res.data && res.data.isSuperIndividual === true) + this.setData({ showSuperCrmEntry: show }) + } catch (_) { + this.setData({ showSuperCrmEntry: false }) + } + }, + async loadVipStatus() { const userId = app.globalData.userInfo?.id if (!userId) return diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index a7b5b310..017c17e0 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -94,7 +94,7 @@ -快捷入口 + diff --git a/miniprogram/pages/my/my.wxss b/miniprogram/pages/my/my.wxss index 51f7dac1..7355438f 100644 --- a/miniprogram/pages/my/my.wxss +++ b/miniprogram/pages/my/my.wxss @@ -201,6 +201,9 @@ display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx; align-items: stretch; } +.stats-grid-4 { + grid-template-columns: repeat(4, 1fr); +} .stat-box { background: #252525; border-radius: 20rpx; padding: 24rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 0932d1fa..8f9b84c9 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -18,7 +18,7 @@ const readingTracker = require('../../utils/readingTracker') const { parseScene } = require('../../utils/scene.js') const contentParser = require('../../utils/contentParser.js') const { trackClick } = require('../../utils/trackClick') -const { checkAndExecute } = require('../../utils/ruleEngine') +const { checkAndExecute, executeRule, getChapterReadMarketingRule } = require('../../utils/ruleEngine') const soulBridge = require('../../utils/soulBridge.js') const app = getApp() @@ -26,25 +26,28 @@ const mpPagePopups = require('../../utils/mpPagePopups.js') /** 与后端 defaultReadPreviewUI / read_preview_ui 配置键一致;占位符 {percent} {price} 在拉章节后替换 */ const READ_UI_DEFAULTS = { - singlePageUnlockTitle: '解锁完整内容', + /** 朋友圈单页简洁版主标题(与分润说明分行) */ + singlePageUnlockTitle: '解锁全文', singlePagePayButtonText: '支付 ¥{price} 解锁全文', /** 单页展开区首行:付款引导;其下为分隔线 + shareTipLine + fullPaywallTip */ singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。', payTapModalTitle: '解锁说明', payTapModalContent: '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - fullUnlockTitle: '加入365读书会', - fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明', - fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读', - fullPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', - notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文', - notLoginPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', - shareTipLine: '好友经你分享购买,你可获得约 90% 收益', + /** 已登录付费墙不再展示大标题/上滑说明,仅保留「购买本章」为主按钮;以下为兼容旧配置占位 */ + fullUnlockTitle: '', + fullUnlockDesc: '', + fullLockedProgressText: '', + /** 底部一句营销(未登录/已登录/单页共用,与后台 read_preview_ui.shareTipLine 一致) */ + fullPaywallTip: '转发给需要的人,一起学习还能赚佣金', + notLoginUnlockDesc: '', + notLoginPaywallTip: '转发给需要的人,一起学习还能赚佣金', + shareTipLine: '转发给需要的人,一起学习还能赚佣金', momentsModalTitle: '分享到朋友圈', momentsModalContent: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。', momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——', - timelineTitleSuffix: '(预览{percent}%)', + timelineImageUrl: '', } /** 章节未返回前:用默认比例与价格生成 readUi,避免云端 read_preview_ui 为空时付费墙/预览条不显示 */ @@ -239,7 +242,20 @@ Page({ hasFullBook: false, canAccess: false, // 保留兼容性,从 accessState 派生 purchasedCount: 0, - fullbookShowThreshold: 2, + /** 购买满 N 小节后展示「加入读书会」;与后台 fullbook_show_threshold 一致,默认 3 */ + fullbookShowThreshold: 3, + /** 是否展示「加入读书会」按钮:满 N 节,或已购≥2 且非本书第一篇(有上一篇) */ + showFullBookCta: false, + /** 浏览不同章节数 ≥ 阈值且双按钮并存时:凸显「加入读书会」;「购买本章」仅视觉次要,仍可付 ¥1 */ + paywall365Primary: false, + /** 付费墙「购买本章」按钮完整 class(主渐变 / 次要描边) */ + sectionPurchaseBtnClass: 'purchase-btn purchase-section purchase-section--primary', + /** 付费墙「加入读书会」按钮完整 class(含弱化/主按钮) */ + fullbookPurchaseBtnClass: 'purchase-btn purchase-fullbook', + /** 用户旅程规则:365 营销锚点(revealMode=anchor 时展示,点击后再弹窗/支付) */ + pay365AnchorVisible: false, + pay365AnchorLabel: '加入 365 读书会,解锁全部内容', + pay365RuleSnapshot: null, // 阅读进度 readingProgress: 0, @@ -248,7 +264,7 @@ Page({ /** 未解锁时:按 previewPercent 裁切可见高度(px)。0 表示未启用/待测量 */ previewMaxHeightPx: 0, /** 与 mpPagePopups READ_PAGE_FALLBACK 一致;getConfig 前首屏即有字 */ - readBeforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。', + readBeforeLoginHint: '', readSinglePageTitle: '解锁完整内容', readSinglePageHint: '当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。', @@ -260,7 +276,7 @@ Page({ // 价格 sectionPrice: 1, - fullBookPrice: 9.9, + fullBookPrice: 365, totalSections: 62, // 弹窗 @@ -275,6 +291,9 @@ Page({ showLoginModal: false, showPrivacyModal: false, showPosterModal: false, + showMomentsModal: false, + momentsCopied: false, + shareRate: 90, isPaying: false, isGeneratingPoster: false, @@ -292,8 +311,9 @@ Page({ // 朋友圈单页模式(scene 1154 / systemInfo.mode):无法登录与支付,仅引导「前往小程序」 readSinglePageMode: false, - // 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal) momentsPaywallExpanded: false, + /** 点「支付解锁全文」时:底部引导浮层(指向系统底栏「前往小程序」) */ + showMomentsLaunchGuideModal: false, // 未付费预览比例(与后端 unpaid_preview_percent / 章节 preview_percent 一致) effectivePreviewPercent: 20, @@ -424,26 +444,15 @@ Page({ this.setData({ effectivePreviewPercent: pct, readUi: filled }) }, - /** 单页模式:点支付解锁 → 弹窗说明 → 展开底栏引导与箭头 */ + /** 单页模式:点支付解锁 → 底部浮层 + 向下箭头动画,引导底栏「前往小程序」 */ onUnlockTapInSinglePage() { trackClick('read', 'btn_click', '单页_解锁引导') - try { - wx.vibrateShort({ type: 'light' }) - } catch (e) {} - const ui = this.data.readUi || {} - wx.showModal({ - title: ui.payTapModalTitle || '解锁说明', - content: - ui.payTapModalContent || - '请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - showCancel: false, - confirmText: '知道了', - success: () => { - if (this._detectReadSinglePage() && typeof this.setData === 'function') { - this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true }) - } - }, - }) + try { wx.vibrateShort({ type: 'medium' }) } catch (_) {} + this.setData({ showMomentsLaunchGuideModal: true }) + }, + + closeMomentsLaunchGuideModal() { + this.setData({ showMomentsLaunchGuideModal: false }) }, onShow() { @@ -451,8 +460,57 @@ Page({ this.setData({ auditMode: app.globalData.auditMode || false, readSinglePageMode: sp, - ...(sp ? {} : { momentsPaywallExpanded: false }), + ...(sp ? {} : { momentsPaywallExpanded: false, showMomentsLaunchGuideModal: false }), }) + this._refreshAccessOnShow() + if (!sp) { + this.refreshPay365MarketingFromRules() + } + }, + + /** + * 从后台回到阅读页时重拉 VIP 与解锁态。 + * 修复:管理端取消超级个体后,若仍用订单兜底 VIP,需配合后端 isVipEffective;同时刷新端上 showPaywall/全文裁切。 + */ + async _refreshAccessOnShow() { + const id = this.data.sectionId + if (!id || this.data.loading || this.data.readSinglePageMode) return + const uid = app.globalData.userInfo?.id + if (!uid) return + try { + await accessManager.refreshUserPurchaseStatus() + const vipRes = await app.request({ + url: `/api/miniprogram/vip/status?userId=${encodeURIComponent(uid)}`, + silent: true, + timeout: 8000 + }) + if (vipRes && vipRes.success && vipRes.data) { + app.globalData.isVip = !!vipRes.data.isVip + app.globalData.vipExpireDate = vipRes.data.expireDate || '' + const u = { ...(app.globalData.userInfo || {}) } + u.isVip = app.globalData.isVip + u.vipExpireDate = app.globalData.vipExpireDate + app.globalData.userInfo = u + try { + wx.setStorageSync('userInfo', u) + } catch (_) {} + } + const mid = this.data.sectionMid + const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true }) + const accessState = await accessManager.determineAccessState(id, chapterRes) + const canAccess = accessManager.canAccessFullContent(accessState) + if (accessState === this.data.accessState && canAccess === this.data.canAccess) return + this.setData({ + accessState, + canAccess, + showPaywall: !canAccess, + isLoggedIn: true + }) + await this.loadContent(id, accessState, chapterRes) + this.refreshPay365MarketingFromRules() + } catch (e) { + console.warn('[Read] onShow 刷新权限失败', e) + } }, async onLoad(options) { @@ -530,6 +588,7 @@ Page({ pendingGiftRequestSn: giftRequestSn || '', readSinglePageMode: this._detectReadSinglePage(), momentsPaywallExpanded: false, + showMomentsLaunchGuideModal: false, }) if (ref) { @@ -542,12 +601,13 @@ Page({ const config = await accessManager.fetchLatestConfig() const purchasedSections = app.globalData.purchasedSections || [] const rawThreshold = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold - const threshold = (rawThreshold != null && Number(rawThreshold) > 0) ? Number(rawThreshold) : 2 + const threshold = (rawThreshold != null && Number(rawThreshold) > 0) ? Number(rawThreshold) : 3 this.setData({ sectionPrice: config.prices?.section ?? 1, - fullBookPrice: config.prices?.fullbook ?? 9.9, + fullBookPrice: config.prices?.fullbook ?? 365, purchasedCount: purchasedSections.length, fullbookShowThreshold: threshold, + shareRate: config.shareRate ?? 90, }) // 统一:先拉章节数据,用 isFree/price===0 判断免费 @@ -590,6 +650,8 @@ Page({ // 5. 导航:文章详情已带 prev/next this._applyPrevNext(chapterRes) + this.computeShowFullBookCta() + this.refreshPay365MarketingFromRules() } catch (e) { console.error('[Read] 初始化失败:', e) @@ -731,6 +793,15 @@ Page({ app.markSectionAsRead(id) } app.touchRecentSection(id) + // 未解锁试读也需触发「浏览 N 章→365」modal;单页模式无法支付由 ruleEngine 侧不弹或用户点引导 + const spMode = this.data.readSinglePageMode + if ( + !spMode && + (accessManager.canAccessFullContent(accessState) || + accessState === accessManager.accessStates.LOCKED_NOT_PURCHASED) + ) { + checkAndExecute('chapter_read', this) + } } } catch (e) { console.error('[Read] 加载内容失败,尝试本地缓存:', e) @@ -783,11 +854,11 @@ Page({ return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 } } - // 普通章节 + // 普通章节(免费状态以后端为准,不在前端硬编码) return { id: id, title: this.getSectionTitle(id), - isFree: id === '1.1', + isFree: false, price: 1 } }, @@ -936,6 +1007,88 @@ Page({ }) }, + /** 是否展示「加入读书会」:满 N 次单章购买后,或已购≥2 且当前不是全书第一篇(有上一篇) */ + computeShowFullBookCta() { + const th = Number(this.data.fullbookShowThreshold) || 3 + const pc = Number(this.data.purchasedCount) || 0 + const hasPrev = !!this.data.prevSection + let show = pc >= th || (pc >= 2 && hasPrev) + if (this.data.pay365AnchorVisible && this.data.pay365RuleSnapshot) { + show = false + } + const browseN = + typeof app.getBrowseDistinctChapterCount === 'function' ? app.getBrowseDistinctChapterCount() : 0 + /** 双按钮且已浏览 ≥N 个不同章节:读书会为主 CTA;单章仅视觉弱化(描边),仍始终可付 ¥1 */ + const paywall365Primary = !!(show && browseN >= th) + + let sectionCls = 'purchase-btn purchase-section' + let fullbookCls = 'purchase-btn purchase-fullbook' + if (!show) { + sectionCls += ' purchase-section--primary' + } else if (paywall365Primary) { + sectionCls += ' purchase-section--secondary' + } else { + sectionCls += ' purchase-section--primary' + fullbookCls += ' purchase-fullbook--secondary' + } + + this.setData({ + showFullBookCta: show, + paywall365Primary, + sectionPurchaseBtnClass: sectionCls, + fullbookPurchaseBtnClass: fullbookCls, + }) + }, + + /** + * 后台规则 revealMode=anchor 时:仅展示锚点条,避免与「加入读书会」双按钮重复 + */ + onPay365MarketingAnchor(rule) { + if (!rule || rule.action !== 'pay365' || rule.revealMode !== 'anchor') return + const label = (rule.anchorLabel && String(rule.anchorLabel).trim()) || '加入 365 读书会,解锁全部内容' + this.setData({ + pay365AnchorVisible: true, + pay365RuleSnapshot: rule, + pay365AnchorLabel: label, + }) + this.computeShowFullBookCta() + }, + + /** 用户点击锚点 → 与规则引擎 executeRule 一致(弹窗确认后支付) */ + onPay365AnchorTap() { + const rule = this.data.pay365RuleSnapshot + if (!rule) return + executeRule(rule, this) + }, + + /** + * 进入阅读页/切章后同步:未登录或无规则时不展示;已购读书会/VIP 不展示 + */ + async refreshPay365MarketingFromRules() { + try { + const uid = app.globalData.userInfo && app.globalData.userInfo.id + if (!uid || app.globalData.hasFullBook || app.globalData.isVip) { + this.setData({ pay365AnchorVisible: false, pay365RuleSnapshot: null }) + this.computeShowFullBookCta() + return + } + const rule = await getChapterReadMarketingRule() + if (rule && rule.revealMode === 'anchor' && rule.action === 'pay365') { + const label = (rule.anchorLabel && String(rule.anchorLabel).trim()) || '加入 365 读书会,解锁全部内容' + this.setData({ + pay365AnchorVisible: true, + pay365RuleSnapshot: rule, + pay365AnchorLabel: label, + }) + } else { + this.setData({ pay365AnchorVisible: false, pay365RuleSnapshot: null }) + } + this.computeShowFullBookCta() + } catch (e) { + console.warn('[Read] refreshPay365MarketingFromRules', e) + } + }, + // 返回(从分享进入无栈时回首页) goBack() { getApp().goBackOrToHome() @@ -1292,15 +1445,25 @@ Page({ } const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}` - const title = section?.title - ? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}` - : '📚 卡若创业派对 - 真实商业故事' + const ui = this.data.readUi || {} + const shareTpl = ui.shareTitle || '' + let title = '' + if (shareTpl && section?.title) { + title = shareTpl.replace(/\{title\}/g, section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title) + } else { + title = section?.title + ? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}` + : '📚 卡若创业派对 - 真实商业故事' + } return { title, path } }, - // 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline) shareToMoments() { trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || '')) + this.setData({ showMomentsModal: true, momentsCopied: false }) + }, + + onMomentsCopy() { const title = this.data.section?.title || this.data.chapterTitle || '好文推荐' const ui = this.data.readUi || {} const paras = (this.data.previewParagraphs || []).filter(Boolean).join('\n\n') @@ -1313,26 +1476,19 @@ Page({ wx.setClipboardData({ data: clipboardText, success: () => { - // 系统会在 setClipboardData 成功后自动 toast「内容已复制」,与下方引导弹窗重复,先关掉再弹窗 wx.hideToast() - setTimeout(() => { - wx.hideToast() - wx.showModal({ - title: ui.momentsModalTitle || '分享到朋友圈', - content: - ui.momentsModalContent || - '已复制发圈文案。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。', - showCancel: false, - confirmText: '知道了' - }) - }, 120) + this.setData({ momentsCopied: true }) }, fail: () => { - wx.showToast({ title: '复制失败,请手动复制', icon: 'none' }) + wx.showToast({ title: '复制失败', icon: 'none' }) } }) }, + closeMomentsModal() { + this.setData({ showMomentsModal: false }) + }, + // 分享到朋友圈:带文章标题,过长时截断 onShareTimeline() { const { section, sectionId, sectionMid, chapterTitle, readUi } = this.data @@ -1341,12 +1497,23 @@ Page({ const query = ref ? `${q}&ref=${ref}` : q const articleTitle = (section?.title || chapterTitle || '').trim() const base = articleTitle || '卡若创业派对 - 真实商业故事' - const suffix = (readUi && readUi.timelineTitleSuffix) || '' - let full = base + suffix + let full = base if (full.length > 30) { full = full.slice(0, 30) + '…' } - return { title: full, query } + const imageRaw = (readUi && readUi.timelineImageUrl) || '' + let imageUrl = '' + if (imageRaw) { + if (/^https?:\/\//i.test(imageRaw)) { + imageUrl = imageRaw + } else { + const baseUrl = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '') + if (baseUrl) { + imageUrl = `${baseUrl}/${String(imageRaw).replace(/^\/+/, '')}` + } + } + } + return imageUrl ? { title: full, query, imageUrl } : { title: full, query } }, // 显示登录弹窗(每次打开协议未勾选,符合审核要求) @@ -1459,13 +1626,13 @@ Page({ await this.processPayment('section', this.data.sectionId, price) }, - // 购买全书 - 直接调起支付 + // 365 读书会(product_type=fullbook)- 直接调起支付 async handlePurchaseFullBook() { if (this.data.readSinglePageMode || this._detectReadSinglePage()) { this.onUnlockTapInSinglePage() return } - console.log('[Pay] 点击购买全书按钮') + console.log('[Pay] 点击加入读书会按钮') wx.showLoading({ title: '处理中...', mask: true }) if (!this.data.isLoggedIn) { @@ -1475,7 +1642,7 @@ Page({ return } - console.log('[Pay] 开始支付流程: 全书', { price: this.data.fullBookPrice }) + console.log('[Pay] 开始支付流程: 365读书会', { price: this.data.fullBookPrice }) wx.hideLoading() await this.processPayment('fullbook', null, this.data.fullBookPrice) }, @@ -1493,24 +1660,21 @@ Page({ } const userInfo = app.globalData.userInfo - if (userInfo?.id) { - const avatar = userInfo.avatarUrl || '' - const nickname = userInfo.nickname || userInfo.nickName || '' - const needProfile = !avatar || avatar.includes('default') || avatar.includes('132') || !nickname || nickname === '微信用户' - if (needProfile) { - const res = await new Promise(resolve => { - wx.showModal({ - title: '设置头像与昵称', - content: '支付订单会关联你的对外展示信息,请先设置头像与昵称,避免账单与对方看到默认占位。', - confirmText: '去设置', - cancelText: '关闭', - success: resolve - }) + // 与 app._needsAvatarNickname 一致:须同时读 avatar 与 avatarUrl(业务侧存 avatar,微信侧可能只有 avatarUrl) + if (userInfo?.id && typeof app._needsAvatarNickname === 'function' && app._needsAvatarNickname(userInfo)) { + const res = await new Promise(resolve => { + wx.showModal({ + title: '设置头像与昵称', + content: + '头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。支付前请先完善,避免订单展示信息不完整。', + confirmText: '去设置', + cancelText: '关闭', + success: resolve }) - if (res.confirm) { - wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) - return - } + }) + if (res.confirm) { + wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' }) + return } } @@ -1544,7 +1708,7 @@ Page({ if (type === 'fullbook' && checkRes.data.hasFullBook) { wx.hideLoading() - wx.showToast({ title: '已购买全书', icon: 'none' }) + wx.showToast({ title: '已加入读书会', icon: 'none' }) return } } @@ -1626,8 +1790,8 @@ Page({ try { // 获取章节完整名称用于支付描述 const sectionTitle = this.data.section?.title || sectionId - const description = type === 'fullbook' - ? '《一场Soul的创业实验》全书' + const description = type === 'fullbook' + ? '加入读书会' : `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}` // 邀请码:与 VIP/钱包一致,优先 storage 落地 ref,否则回落本人推荐码便于自购归因 @@ -1744,6 +1908,10 @@ Page({ // 2. 刷新用户购买状态 await accessManager.refreshUserPurchaseStatus() + const pcAfter = (app.globalData.purchasedSections || []).length + this.setData({ purchasedCount: pcAfter }) + this.computeShowFullBookCta() + this.refreshPay365MarketingFromRules() // 3. 重新拉取章节并判断权限(应为 unlocked_purchased) const chapterRes = await app.request({ @@ -1820,6 +1988,8 @@ Page({ wx.setStorageSync('userInfo', userInfo) this.setData({ purchasedCount: (res.data.purchasedSections || []).length }) + this.computeShowFullBookCta() + this.refreshPay365MarketingFromRules() console.log('[Pay] ✅ 购买状态已刷新:', { hasFullBook: res.data.hasFullBook, purchasedCount: res.data.purchasedSections.length @@ -2134,10 +2304,10 @@ Page({ const config = await accessManager.fetchLatestConfig() const purchasedSections2 = app.globalData.purchasedSections || [] const rawTh2 = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold - const th2 = (rawTh2 != null && Number(rawTh2) > 0) ? Number(rawTh2) : 2 + const th2 = (rawTh2 != null && Number(rawTh2) > 0) ? Number(rawTh2) : 3 this.setData({ sectionPrice: config.prices?.section ?? 1, - fullBookPrice: config.prices?.fullbook ?? 9.9, + fullBookPrice: config.prices?.fullbook ?? 365, purchasedCount: purchasedSections2.length, fullbookShowThreshold: th2, }) @@ -2167,6 +2337,8 @@ Page({ } this._applyPrevNext(chapterRes) + this.computeShowFullBookCta() + this.refreshPay365MarketingFromRules() wx.hideLoading() wx.showToast({ title: '加载成功', icon: 'success' }) diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index d4335f42..4c7533eb 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -132,7 +132,7 @@+ 订单 @@ -110,6 +110,11 @@{{walletBalanceText}} 我的余额 + + 客资 +链接与轨迹 +- {{readUi.shareTipLine || ('好友经你分享购买,你可获得约 ' + (shareRate || 90) + '% 收益')}} +{{readUi.shareTipLine}} - - -+ + + @@ -245,47 +251,51 @@+ - - 解锁完整内容,分享得到 -{{shareRate || 90}}% -收益 ++ -+ 解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 +- - 支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文 ++ + {{readUi.singlePagePayButtonText}} 审核中,暂不支持购买 - {{readUi.fullUnlockTitle || '加入365读书会'}} -{{readBeforeLoginHint}} -{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}} ++ + +解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 +- + 购买本章 -¥{{section && section.price != null ? section.price : sectionPrice}} +¥{{section && section.price != null ? section.price : sectionPrice}} - 手机号登录后购买 {{readUi.notLoginPaywallTip || ('分享给好友,好友购买你可获得约 ' + (shareRate || 90) + '% 收益')}} - + + + @@ -486,4 +496,14 @@+ - - 解锁完整内容,分享得到 -{{shareRate || 90}}% -收益 ++ -+ 解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 +- - 支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文 ++ + {{readUi.singlePagePayButtonText}} 审核中,暂不支持购买 - {{readUi.fullUnlockTitle || '加入365读书会'}} -{{readUi.fullUnlockDesc || '可先上滑阅读预览'}} -{{readUi.fullLockedProgressText}} -已阅读约 {{effectivePreviewPercent}}% ,购买后继续阅读 -- - 购买本章 -¥{{section && section.price != null ? section.price : sectionPrice}} ++ + -解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 - +- -- 解锁全部 {{totalSections}} 章 -- +¥{{fullBookPrice}} -省82% -+ +365 +{{pay365AnchorLabel}} +› ++ + +购买本章 +¥{{section && section.price != null ? section.price : sectionPrice}} ++ 加入读书会 +¥{{fullBookPrice}} 审核中,暂不支持购买 -{{readUi.fullPaywallTip || ('分享给好友,好友购买你可获得约 ' + (shareRate || 90) + '% 收益')}} + {{pay365AnchorLabel}} +› ++ diff --git a/miniprogram/pages/super-crm/super-crm.wxss b/miniprogram/pages/super-crm/super-crm.wxss new file mode 100644 index 00000000..68d2db22 --- /dev/null +++ b/miniprogram/pages/super-crm/super-crm.wxss @@ -0,0 +1,151 @@ +.page { + min-height: 100vh; + background: #0a0a0a; + padding-bottom: 48rpx; +} + +.nav-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(10, 10, 10, 0.95); + backdrop-filter: blur(40rpx); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + height: 88rpx; +} +.nav-back { + width: 64rpx; + height: 64rpx; + background: #1c1c1e; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.nav-title { + font-size: 34rpx; + font-weight: 600; + color: #fff; +} +.nav-placeholder { + width: 64rpx; +} +.nav-placeholder-block { + width: 100%; +} + +.tip-card { + margin: 24rpx 24rpx 16rpx; + padding: 24rpx; + background: #1c1c1e; + border-radius: 20rpx; + border: 2rpx solid rgba(79, 209, 197, 0.2); +} +.tip-text { + display: block; + font-size: 28rpx; + color: #e5e7eb; + margin-bottom: 12rpx; +} +.tip-sub { + font-size: 22rpx; + line-height: 1.6; + color: rgba(255, 255, 255, 0.45); +} + +.tabs { + display: flex; + margin: 0 24rpx 16rpx; + background: #1c1c1e; + border-radius: 16rpx; + padding: 6rpx; +} +.tab { + flex: 1; + text-align: center; + padding: 20rpx 0; + font-size: 28rpx; + color: rgba(255, 255, 255, 0.45); + border-radius: 12rpx; +} +.tab-active { + background: #252525; + color: #4fd1c5; + font-weight: 600; +} + +.list-scroll { + height: calc(100vh - 320rpx); + padding: 0 24rpx; + box-sizing: border-box; +} + +.card { + background: #252525; + border-radius: 20rpx; + padding: 24rpx; + margin-bottom: 20rpx; +} +.track-card .action-line { + font-size: 26rpx; + color: #e5e7eb; + line-height: 1.5; + margin-top: 8rpx; +} +.card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 8rpx; +} +.name { + font-size: 30rpx; + font-weight: 600; + color: #fff; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 16rpx; +} +.time { + font-size: 22rpx; + color: #6b7280; + flex-shrink: 0; +} +.source { + display: block; + font-size: 24rpx; + color: #4fd1c5; + margin-bottom: 8rpx; +} +.row { + display: block; + font-size: 24rpx; + color: #d1d5db; + margin-top: 4rpx; +} +.row.muted { + color: #6b7280; + font-size: 22rpx; +} + +.empty { + padding: 120rpx 32rpx; + text-align: center; + font-size: 28rpx; + color: #6b7280; +} + +.load-more { + text-align: center; + padding: 24rpx; + font-size: 24rpx; + color: #6b7280; +} diff --git a/miniprogram/pages/vip/vip.js b/miniprogram/pages/vip/vip.js index 7e9f4874..8a9d2e1e 100644 --- a/miniprogram/pages/vip/vip.js +++ b/miniprogram/pages/vip/vip.js @@ -20,7 +20,7 @@ Page({ ], socialRights: [ { key: 'cases', title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' }, - { key: 'fullbook', title: '全书解锁', desc: '365天全案精读', icon: 'folder' }, + { key: 'fullbook', title: '加入读书会', desc: '全年可读', icon: 'folder' }, { key: 'daily', title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' }, { key: 'leads', title: '获取客资', desc: '文章@你即可获客', icon: 'link' } ], diff --git a/miniprogram/utils/chapterAccessManager.js b/miniprogram/utils/chapterAccessManager.js index 49ed1e24..79e00562 100644 --- a/miniprogram/utils/chapterAccessManager.js +++ b/miniprogram/utils/chapterAccessManager.js @@ -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 } } diff --git a/miniprogram/utils/contentParser.js b/miniprogram/utils/contentParser.js index f3575dab..9299a909 100644 --- a/miniprogram/utils/contentParser.js +++ b/miniprogram/utils/contentParser.js @@ -174,52 +174,189 @@ function parseBlockToSegments(block, config) { return segs } +/** + * 解析+ ++ ++ 链接客资 ++ + + + + +绑定人物:{{personName}} +客资按用户去重;行为轨迹与推送 Webhook 同源,近 1 小时同行为已合并展示 ++ + + +客资 +行为轨迹 ++ + + ++ +暂无留资记录 ++ ++ +{{item.name || item.nickname || '访客'}} +{{item.createdAtText}} +来源:{{item.sourceLabel || item.source || '-'}} +手机:{{item.phone}} +微信:{{item.wechatId}} +推送:{{item.pushStatus}} +加载中... +上拉加载更多 +没有更多了 ++ ++ +暂无行为轨迹 ++ ++ +{{item.userNickname || item.userShort}} +{{item.timeAgo || item.createdAtText}} +{{item.actionLabel}}{{item.chapterTitle ? ' · ' + item.chapterTitle : ''}}{{item.target ? ' · ' + item.target : ''}} +{{item.moduleLabel}} +加载中... +上拉加载更多 +HTML 为结构化数据 { headers: string[], rows: string[][] } + */ +function parseTableHtml(tableHtml) { + const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim()) + const headers = [] + const rows = [] + + const headMatch = tableHtml.match(/]*>([\s\S]*?)<\/thead>/i) + if (headMatch) { + const thRe = /
]*>([\s\S]*?)<\/th>/gi + let m + while ((m = thRe.exec(headMatch[1])) !== null) { + headers.push(stripTags(m[1])) + } + } + + const bodyMatch = tableHtml.match(/ ]*>([\s\S]*?)<\/tbody>/i) + const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml + const trRe = /]*>([\s\S]*?)<\/tr>/gi + let trM + let isFirst = !headMatch + while ((trM = trRe.exec(bodyContent)) !== null) { + if (headMatch && trM[0] === (headMatch[0].match(/ ]*>[\s\S]*?<\/tr>/i) || [])[0]) continue + const cells = [] + const cellRe = / ]*>([\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. 提取 块 + const tables = [] + let text = html.replace(/
]*>[\s\S]*?<\/table>/gi, (match) => { + const idx = tables.length + tables.push(parseTableHtml(match)) + return '\n__TABLE_' + idx + '__\n' + }) + + // 1. 提取
/
→ heading 占位 + const headings = [] + text = text.replace(/
]*>([\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. 提取 → quote 占位 + const quotes = [] + text = text.replace(/]*>([\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(/]*>/gi, function () { olDepth++; olCounter = 0; return '\n' }) + text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' }) + text = text.replace(/
]*>/gi, '\n') + text = text.replace(/<\/ul>/gi, '\n') + text = text.replace(/
- ]*>([\s\S]*?)<\/li>/gi, function (_, inner) { + var cleaned = inner.replace(/
]*>/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*
]*>/gi, '\n\n') text = text.replace(/
]*>/gi, '') text = text.replace(/<\/p>/gi, '\n') text = text.replace(/
]*>/gi, '') text = text.replace(/<\/div>/gi, '\n') text = text.replace(/
/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(/- ]*>/gi, '• ') - text = text.replace(/<\/li>/gi, '\n') + text = text.replace(/
/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) } diff --git a/miniprogram/utils/mpPagePopups.js b/miniprogram/utils/mpPagePopups.js index 05c9be5a..6eaf5162 100644 --- a/miniprogram/utils/mpPagePopups.js +++ b/miniprogram/utils/mpPagePopups.js @@ -14,7 +14,8 @@ const READ_PATH = '/pages/read/read' /** 阅读页 mpUi 兜底(pagePopupItems / readPage 均无或为空时) */ const READ_PAGE_FALLBACK = { - beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。', + /** 已不在付费墙展示长说明;保留短句供兼容旧配置拉取 */ + beforeLoginHint: '', singlePageTitle: '解锁完整内容', singlePagePaywallHint: '当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。', diff --git a/miniprogram/utils/partIcons.js b/miniprogram/utils/partIcons.js index 24f59c5f..3444eb18 100644 --- a/miniprogram/utils/partIcons.js +++ b/miniprogram/utils/partIcons.js @@ -2,7 +2,7 @@ * 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致 * 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时) */ -const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖'] +const PART_ICONS = ['🧭', '🌍', '⚡', '💎', '🏆', '🚀', '🎯', '🔑', '🌟', '🎪'] /** 正文篇在列表中的从 0 开始的序号 → emoji(与 ChapterTree bodyPartOrdinal 一致) */ function partEmojiForBodyIndex(bodyIndex) { diff --git a/miniprogram/utils/ruleEngine.js b/miniprogram/utils/ruleEngine.js index 2f221679..3ce4490c 100644 --- a/miniprogram/utils/ruleEngine.js +++ b/miniprogram/utils/ruleEngine.js @@ -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, +} diff --git a/miniprogram/utils/soulBridge.js b/miniprogram/utils/soulBridge.js index a780f6e1..845f3699 100644 --- a/miniprogram/utils/soulBridge.js +++ b/miniprogram/utils/soulBridge.js @@ -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 diff --git a/scripts/batch_upload_ip_book.py b/scripts/batch_upload_ip_book.py new file mode 100644 index 00000000..a6aa4293 --- /dev/null +++ b/scripts/batch_upload_ip_book.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量上传《卡若的IP财富旅程》PART1-5 到小程序。 + +part_id : part-new-17755513182 +part_title: 卡若的IP财富旅程 +各 PART 对应独立 chapter: + PART1 -> chapter-ip-1 | PART1 | 我少年时期的互联网启蒙 + PART2 -> chapter-ip-2 | PART2 | 从0-1的过程 + PART3 -> chapter-ip-3 | PART3 | 我的创业旅程 + PART4 -> chapter-ip-4 | PART4 | 私域流量到AI时代的商业进化 + PART5 -> chapter-ip-5 | PART5 | 未来的一些思考 + +ID 格式: ip. 例: ip1.1, ip2.10, ip3.5 +""" +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +# 匹配 ✅1.5-标题 或 ✅1.5- 标题 形式的文件名,提取 section_num 和 title +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + """返回 [(ip_id, title, path)] 已去重且按节号排序""" + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + # 去掉 .md 后缀再匹配(处理双后缀 .md.md) + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + # 把文件名里的冒号/括号等清理出来作为标题 + title = title_raw.strip(":: \t") + # 还原完整标题(包含冒号) + title = title_raw + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + + # 优先选有 .md 后缀的文件(去重) + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + # 若新文件有 .md 后缀,替换 + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + # 按节号排序 + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, ip_id), + ) + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_skip = 0 + total_err = 0 + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for ip_id, title, path in files: + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败 {path.name}: {e}") + total_err += 1 + continue + + body = for_miniprogram_body(strip_md_title_line(raw)) + if dry_run: + print(f" [dry] {ip_id} {title[:30]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter(cur, chapter_id, chapter_title, ip_id, title, body, price) + conn.commit() + print(f" ✓ [{action}] {ip_id} {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 跳过 {total_skip} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/batch_upload_ip_book_preserve_images.py b/scripts/batch_upload_ip_book_preserve_images.py new file mode 100644 index 00000000..6dddeabc --- /dev/null +++ b/scripts/batch_upload_ip_book_preserve_images.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +《卡若的IP财富旅程》PART1-5 重新上传:正文以本地 MD 为准,已入库的图片块按顺序保留(不重新上传)。 + +- 从 chapters.content 提取现有 (或裸
)块,顺序与 MD 中独立图片行一一对应。 +- 无图片的章节:与 batch_upload_ip_book.py 相同(纯文本清洗)。 +- 同步 sort_order(与 fix_ip_book_sort_and_images 一致)。 +""" +from __future__ import annotations + +import html +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + +LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body_plain(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def extract_img_blocks_from_db(old_content: str) -> list[str]: + """从已入库正文中按文档顺序提取图片块(供复用 URL)。""" + if not old_content or "
]*>\s*(
]+/?>)\s*", s, re.IGNORECASE | re.DOTALL): + blocks.append(m.group(0).strip()) + if blocks: + return blocks + for m in re.finditer(r"
]+/?>", s, re.IGNORECASE): + blocks.append(f"
{m.group(0).strip()}
") + return blocks + + +def body_html_preserve_images(md_path: Path, old_content: str) -> tuple[str, list[str]]: + """ + 按 fix_ip_book 的 HTML 段落规则生成正文,图片行使用旧库中的块。 + 返回 (body, warnings) + """ + warnings: list[str] = [] + old_imgs = extract_img_blocks_from_db(old_content) + raw = md_path.read_text(encoding="utf-8") + lines = strip_md_title_line(raw).splitlines() + + img_idx = 0 + md_img_lines = sum(1 for ln in lines if LINE_IMAGE_ONLY.match(ln)) + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + if img_idx < len(old_imgs): + chunks.append(old_imgs[img_idx]) + img_idx += 1 + else: + warnings.append(f"MD 图片行多于库中已存图片 ({md_path.name}),已跳过该行图片") + chunks.append("(图片:库中无对应已上传块,请检查)
") + continue + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"{html.escape(stripped)}
") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + body = "\n".join(html_parts).strip() + "\n" + + rest = len(old_imgs) - img_idx + if rest > 0: + warnings.append(f"{md_path.name}: 库中多 {rest} 张图片未在 MD 中引用,已从新正文移除(按 MD 为准)") + if md_img_lines and not old_imgs: + warnings.append(f"{md_path.name}: MD 含图片行但库中正文无,请先跑 fix_ip_book_sort_and_images 上传图片,或使用纯文本上传") + + return body, warnings + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + title = title_raw + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def md_has_image_only_lines(md_path: Path) -> bool: + raw = md_path.read_text(encoding="utf-8") + text = strip_md_title_line(raw) + return any(LINE_IMAGE_ONLY.match(ln) for ln in text.splitlines()) + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float, sort_order: int): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s, sort_order=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order, ip_id), + ) + return "updated" + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price, sort_order) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_err = 0 + all_warnings: list[str] = [] + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + try: + has_imgs = md_has_image_only_lines(path) + if has_imgs: + cur.execute("SELECT content FROM chapters WHERE id = %s", (ip_id,)) + row = cur.fetchone() + old_content = row[0] if row and row[0] else "" + body, warns = body_html_preserve_images(path, old_content) + for w in warns: + all_warnings.append(f"{ip_id}: {w}") + else: + raw = path.read_text(encoding="utf-8") + body = for_miniprogram_body_plain(strip_md_title_line(raw)) + except Exception as e: + print(f" ✗ 处理失败 {ip_id} ({path.name}): {e}") + total_err += 1 + continue + + if dry_run: + mode = "html+保留图" if has_imgs else "plain" + print(f" [dry] {ip_id} sort={sort_order} [{mode}] {title[:36]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter( + cur, chapter_id, chapter_title, ip_id, title, body, price, sort_order + ) + conn.commit() + mode = "html" if has_imgs else "plain" + print(f" ✓ [{action}] {ip_id} sort={sort_order} [{mode}] {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if all_warnings: + print("\n⚠ 提示:") + for w in all_warnings[:50]: + print(f" - {w}") + if len(all_warnings) > 50: + print(f" ... 另有 {len(all_warnings) - 50} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_ip_book_sort_and_images.py b/scripts/fix_ip_book_sort_and_images.py new file mode 100644 index 00000000..df3b641d --- /dev/null +++ b/scripts/fix_ip_book_sort_and_images.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +修复《卡若的IP财富旅程》两个问题: +1. 排序错误:设置 sort_order(ip1.1=1, ip1.2=2, ...)使章节顺序正确 +2. 图片缺失:上传 ../images/ 里的图片到 OSS,更新 content 为含
的 HTML + +用法: + python3 scripts/fix_ip_book_sort_and_images.py + python3 scripts/fix_ip_book_sort_and_images.py --dry-run # 仅预览,不写库 +""" +from __future__ import annotations + +import html +import importlib.util +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PART_ID = "part-new-17755513182" + +CHAPTERS = { + 1: "chapter-ip-1", + 2: "chapter-ip-2", + 3: "chapter-ip-3", + 4: "chapter-ip-4", + 5: "chapter-ip-5", +} + +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def resolve_image(md_path: Path, ref: str) -> Path | None: + if ref.startswith(("http://", "https://")): + return None + p = (md_path.parent / ref).expanduser().resolve() + if p.is_file() and p.suffix.lower() in IMAGE_EXT: + return p + return None + + +def guess_mime(path: Path) -> str: + return {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp"}.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + try: + import requests + except ImportError: + sys.exit("需要: pip install requests") + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def has_image_refs(md_path: Path) -> bool: + text = md_path.read_text(encoding="utf-8") + return bool(re.search(r"^\s*!\[", text, re.MULTILINE)) + + +def md_to_html(md_path: Path, upload_cache: dict[str, str], dry_run: bool) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + chunks.append(f'
') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"
(图片路径无效:{html.escape(ref)})
") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'') + continue + # 普通文字行 + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"
{html.escape(stripped)}
") + else: + chunks.append("") + + # 压缩多余空行 + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def plain_body(md_path: Path) -> str: + """无图片章节:纯文本清洗(与原 batch_upload 一致)""" + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines).replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + seen: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + clean = re.sub(r"\.md(\.md)*$", "", f.name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean) + if not m or int(m.group(1)) != part_num: + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + ip_id = f"ip{p_num}.{s_num}" + key = f"{p_num}.{s_num}" + if key in seen: + if str(f).lower().endswith(".md") and not str(seen[key][2]).lower().endswith(".md"): + seen[key] = (ip_id, title_raw, f) + else: + seen[key] = (ip_id, title_raw, f) + result = list(seen.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_sort_fixed = 0 + total_img_updated = 0 + total_plain_updated = 0 + + for part_num, chapter_id in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} ({chapter_id})") + print(f"{'='*60}") + files = collect_files(part_num) + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + has_imgs = has_image_refs(path) + img_tag = "🖼" if has_imgs else " " + print(f"\n {img_tag} {ip_id} sort={sort_order} {title[:35]} ({path.name})") + + if has_imgs: + body = md_to_html(path, upload_cache, dry_run) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_img_updated += 1 + total_sort_fixed += 1 + else: + body = plain_body(path) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_plain_updated += 1 + total_sort_fixed += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"排序修复: {total_sort_fixed} 条") + print(f"图片章节更新: {total_img_updated} 条") + print(f"纯文本章节更新: {total_plain_updated} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/optimize_28_models.py b/scripts/optimize_28_models.py new file mode 100644 index 00000000..1a01ce84 --- /dev/null +++ b/scripts/optimize_28_models.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +上传/更新《28套私域商业模式》全部28章到小程序 + +功能: +- Markdown → 语义化 HTML(保留 h2/h3/blockquote/ul/ol/li/table/img) +- 图片自动上传到 OSS,替换本地路径 +- 预处理:去 # 标题行、去 ---、扁平化嵌套列表 +- 后处理:去
、去- 内
、合并空行、修孤立 bullet +- 写入 DB 后需要通过 admin API 清 Redis 缓存(脚本不含此步,见下方) + +用法: + python3 scripts/optimize_28_models.py --dry-run # 预览(不上传/不写库) + python3 scripts/optimize_28_models.py # 执行上传+写库 + +清缓存(上传完后手动执行): + 通过 admin API PUT /api/db/book 触发 cache.InvalidateChapterContent() + 参考本对话上下文中的缓存失效脚本 + +小程序前端要求: + contentParser.js 需支持 heading/quote/listItem/table/image 段落类型 + read.wxml/read.wxss 需有对应渲染组件与样式 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PARTS = [ + ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), + ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), + ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), +] + +MODEL_NUM_RE = re.compile(r"模式(\d+)") +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def preprocess_markdown(md_text: str) -> str: + """预处理 Markdown 源文本,解决嵌套列表产生的孤立 bullet 问题""" + lines = md_text.splitlines() + + # 去掉第一行如果是 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + result = [] + for line in lines: + # 把 "---" 分隔线去掉(标题已经能分隔段落) + if re.match(r"^\s*---+\s*$", line): + continue + result.append(line) + + # 合并嵌套列表:把 " - xxx" 子项合并为同级 "- xxx" + flat = [] + for line in result: + # 检测 2 空格或 4 空格缩进的子列表项 + m = re.match(r"^(\s{2,4})[-*]\s+(.*)", line) + if m: + flat.append(f"- {m.group(2)}") + else: + flat.append(line) + + return "\n".join(flat).strip() + + +def md_to_html(md_text: str) -> str: + """Markdown → 干净的 HTML""" + cleaned = preprocess_markdown(md_text) + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = clean_html(html) + return html.strip() + + +def clean_html(html: str) -> str: + """清理 HTML:去
、去- 内
、合并空标签、修孤立 bullet""" + + # 去掉所有
标签 + html = re.sub(r"
", "", html, flags=re.IGNORECASE) + + # 去掉- 内部的 包裹(防止 contentParser 把 bullet 和文本拆开) + #
- \n
→content
\n- content
+ html = re.sub( + r"- \s*
", + r"([\s\S]*?)
\s*- \1
", + html, + flags=re.IGNORECASE, + ) + + # 去掉空的 + html = re.sub(r"- \s*
", "", html, flags=re.IGNORECASE) + + # 去掉空的 + html = re.sub(r"\s*
", "", html, flags=re.IGNORECASE) + + # 把内的 "- xxx" 列表项拆出来为
- 格式 + def fix_inline_list(m): + before = m.group(1).strip() + body = m.group(2) + items = re.findall(r"-\s*(.+)", body) + if not items: + return m.group(0) + parts = [f"
{before}
"] if before else [] + parts.append("") + for item in items: + parts.append(f"
") + return "\n".join(parts) + + html = re.sub( + r"- {item.strip()}
") + parts.append("(.*?)\n((?:\s*-\s+.+\n?)+)
", + fix_inline_list, + html, + flags=re.IGNORECASE, + ) + + # 压缩 block 标签之间的空白:\n\n→ \n+ html = re.sub( + r"((?:blockquote|ul|ol|table|h[1-6]|p|div)>)\s*\n\s*\n\s*(<(?:h[1-6]|blockquote|ul|ol|table|p|div)[^>]*>)", + r"\1\n\2", + html, + flags=re.IGNORECASE, + ) + + # 合并连续 3+ 换行为 1 个 + html = re.sub(r"\n{3,}", "\n", html) + + return html + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def verify_segments(html: str, chapter_id: str): + """验证 HTML 渲染后不会出现孤立 bullet 和过多空行""" + import re as _re + text = html + text = _re.sub(r" ]*>[\s\S]*?
", "[TABLE]", text, flags=_re.IGNORECASE) + text = _re.sub(r"\s*]*>", "\n\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?h[1-6][^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?blockquote[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?ul[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?ol[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"- ]*>", "• ", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "[IMG]", text, flags=_re.IGNORECASE) + text = _re.sub(r"<[^>]+>", "", text) + + # 模拟 contentParser 的 split(/\n+/) 行为 + blocks = [b.strip() for b in re.split(r"\n+", text) if b.strip()] + issues = [] + for i, block in enumerate(blocks): + if block == "•" or block == "·": + issues.append(f" 段{i}: 孤立 bullet '{block}'") + + if issues: + print(f" ⚠ {chapter_id} 检测到 {len(issues)} 个格式问题:") + for issue in issues[:5]: + print(issue) + else: + print(f" ✓ {chapter_id} 格式检查通过") + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + verify_segments(html_content, ip_id) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_28_models_html.py b/scripts/reupload_28_models_html.py new file mode 100644 index 00000000..1cd58522 --- /dev/null +++ b/scripts/reupload_28_models_html.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《28套私域商业模式》:Markdown → HTML(含表格/图片/粗体/标题) +1. 遍历 3 篇目录,读取 .md 文件 +2. 把 Markdown 转为 HTML(保留表格、图片、引用、列表、粗体等) +3. 上传本地图片到 OSS,替换为线上 URL +4. 更新 chapters 表的 content 字段 + +用法: + python3 scripts/reupload_28_models_html.py --dry-run # 预览 + python3 scripts/reupload_28_models_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PARTS = [ + ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), + ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), + ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), +] + +MODEL_NUM_RE = re.compile(r"模式(\d+)") +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = re.sub(r"\n{3,}", "\n\n", html) + return html.strip() + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + """返回 [(model_num, section_title, path)]""" + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + preview = html_content[:200].replace("\n", " ") + print(f" [dry] 预览: {preview}...") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_gps_html.py b/scripts/reupload_gps_html.py new file mode 100644 index 00000000..cea25892 --- /dev/null +++ b/scripts/reupload_gps_html.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《游戏明星GPS导航》:Markdown → 高质量 HTML(表格/列表/粗体/标题/引用/图片)。 +与 reupload_28_models_html.py 同样使用 python-markdown 做完整转换。 + +用法: + python3 scripts/reupload_gps_html.py --dry-run # 预览 + python3 scripts/reupload_gps_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《游戏明星GPS导航》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + +BOOK_STRUCTURE = [ + ("part-gps-0", "序、什么是游戏明星", "chapter-gps-0", "什么是游戏明星", [ + (1, "敬告", "序_什么是游戏明星/敬告.md"), + (2, "誓言", "序_什么是游戏明星/誓言.md"), + (3, "一、什么是游戏明星", "序_什么是游戏明星/一、什么是游戏明星.md"), + (4, "二、为什么要做游戏明星", "序_什么是游戏明星/二、为什么要做游戏明星.md"), + (5, "三、你好,未来的游戏明星", "序_什么是游戏明星/三、方向不对,努力全费...三、你好,未来的游戏明星.md"), + (6, "四、赚回十倍学费的一个小礼物", "序_什么是游戏明星/四、赚回十倍学费的一个小礼物.md"), + (7, "五、卡若与玩值电竞的故事", "序_什么是游戏明星/五、卡若与玩值电竞的故事.md"), + ]), + ("part-gps-1", "第一章、打开粉丝宝库的钥匙", "chapter-gps-1", "打开粉丝宝库的钥匙", [ + (1, "粉丝是如何判断你有没有吸引力的", "第1章_打开粉丝宝库的钥匙/粉丝是如何判断你有没有吸引力的.md"), + (2, "一、我是如何发现这些粉丝的秘籍的", "第1章_打开粉丝宝库的钥匙/一、我是如何发现这些粉丝的秘籍的(打开粉丝宝库的钥匙).md"), + (3, "二、目标粉丝聚集地(你的粉丝在哪里?)", "第1章_打开粉丝宝库的钥匙/二、目标粉丝聚集地(你的粉丝在哪里?).md"), + (4, "三、价值输出载体(找出你与粉丝交流的桥梁)", "第1章_打开粉丝宝库的钥匙/三、价值输出载体(找出你与粉丝交流的桥梁).md"), + (5, "四、不为人知的具体吸粉秘籍(总论)", "第1章_打开粉丝宝库的钥匙/四,不为人知的具体吸粉秘籍...(手把手带你吸粉).md"), + (6, "4.1、斗鱼直播间粉丝自动流入", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.1,斗鱼直播间粉丝自动流入【吸粉秘籍】.md"), + (7, "4.2、原创游戏视频大脑缺口弥补术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.2,原创游戏视频大脑缺口弥补术【吸粉秘籍】.md"), + (8, "4.3、批量渗透玩家微信群", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.3,批量渗透玩家微信群【吸粉秘籍】.md"), + (9, "4.4、朋友圈粉丝自我说服裂变", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.4,朋友圈粉丝自我说服裂变【吸粉秘籍】.md"), + (10, "4.5、淘宝老客户快速转移进入你的领地", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.5、淘宝老客户快速转移进入你的领地【吸粉秘籍】.md"), + (11, "4.6、人性换粉(威力巨大)", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.6,人性换粉(不管你在任何时间,任何场合永远有效,威力巨大).md"), + (12, "QQ空间红包引流术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/QQ空间红包引流术.md"), + (13, "微博问答降维吸粉", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/10,微博问答降维人吸粉.md"), + (14, "一个提醒", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/一个提醒.md"), + (15, "五、在这章你学到的东西,和将要得到的东西", "第1章_打开粉丝宝库的钥匙/五、在这章你学到的东西,和将要得到的东西.md"), + ]), + ("part-gps-2", "第二章、粉丝互动养熟秘籍", "chapter-gps-2", "粉丝互动养熟秘籍", [ + (1, "粉丝对你的信任程度,与你的收入成正比", "第2章_粉丝互动养熟秘籍/粉丝对你的信任程度,以你的收入成正比。.md"), + (2, '让粉丝疯狂追捧的\u201c聊天技巧\u201d在哪里?', '第2章_粉丝互动养熟秘籍/让粉丝疯狂追棒的\u201c聊天技巧\u201d在哪里?.md'), + (3, "一、粉丝信任升级逻辑", "第2章_粉丝互动养熟秘籍/一、粉丝信任升级逻辑.md"), + (4, "二、如何在互动中,快速制造粉丝对你的好印象", "第2章_粉丝互动养熟秘籍/二、如何在互动中,快速制造粉丝对你的好印象.md"), + (5, "三、神奇的信任升级导图", "第2章_粉丝互动养熟秘籍/三、神奇的信任升级导图.md"), + (6, "四、3种立即见效的日常互动模型", "第2章_粉丝互动养熟秘籍/四、3种立既见效的日常互动模型.md"), + (7, "五、3个神秘的互动秘诀", "第2章_粉丝互动养熟秘籍/五、3个神秘的互动秘诀.md"), + (8, "六、4个步骤过滤超级粉丝", "第2章_粉丝互动养熟秘籍/六、4个步骤过滤超级粉丝.md"), + (9, "七、日常规划", "第2章_粉丝互动养熟秘籍/七、日常规划.md"), + ]), + ("part-gps-3", "第三章、粉丝转化系统", "chapter-gps-3", "粉丝转化系统", [ + (1, "一、转化的四个前提", "第3章_粉丝转化系统/一、转化的四个前提.md"), + (2, "二、破解转化障碍", "第3章_粉丝转化系统/二、破解转化障碍.md"), + (3, "三、行之有效的转化秘籍", "第3章_粉丝转化系统/三、行之有效的转化秘籍.md"), + (4, "3.1、钟摆楼:一分钟获得变现的机会", "第3章_粉丝转化系统/3.1 钟摆楼,如何在一分钟之内获得变现的机会.md"), + (5, "3.2、社群价值裂变", "第3章_粉丝转化系统/3.2 激起兴趣的一些具体方法,微信群价值裂变.md"), + (6, "3.3、直播间转化实战", "第3章_粉丝转化系统/3.3 直播间转化实战.md"), + (7, "如何真正地掌握这套转化系统", "第3章_粉丝转化系统/一些想说的话,如何真正地掌握这套转化系统.md"), + ]), + ("part-gps-4", "第四章、超级粉丝", "chapter-gps-4", "超级粉丝", [ + (1, "一、粉丝未来价值的重要性", "第4章_超级粉丝/一、粉丝未来价值的重要性.md"), + (2, "二、多维度升级为超级粉丝的谋略", "第4章_超级粉丝/二、多维度升级为超级粉丝的谋略.md"), + (3, "三、立体放大未来价值", "第4章_超级粉丝/三、立体放大未来价值.md"), + (4, "四、从超级粉丝到合伙人", "第4章_超级粉丝/四、从超级粉丝到合伙人.md"), + ]), + ("part-gps-5", "第五章、提升影响力的几个细节", "chapter-gps-5", "提升影响力的几个细节", [ + (1, "一、当你觉得Hold不住自己野心时的处理方法", "第5章_提升影响力的几个细节/一、当你觉得Hold不住自己野心时的处理方法.md"), + (2, "二、保持你的个性,创造自己的小领地", "第5章_提升影响力的几个细节/二、保持你的个性,创造自己的小领地.md"), + (3, "三、如何让粉丝支持你", "第5章_提升影响力的几个细节/三、如何让粉丝和支持你.md"), + (4, "四、提升影响力前必须注意的细节", "第5章_提升影响力的几个细节/四、提升影响力前必须注意的细节.md"), + (5, "五、潜意识认同", "第5章_提升影响力的几个细节/五、潜意识认同.md"), + (6, "细节一、头像(视觉锤)", "第5章_提升影响力的几个细节/提升影响力的具体细节/一、头像.md"), + (7, "细节二、关联性昵称", "第5章_提升影响力的几个细节/提升影响力的具体细节/二、关联性昵称.md"), + (8, "细节三、个性签名", "第5章_提升影响力的几个细节/提升影响力的具体细节/三、个性签名.md"), + (9, "细节四、定位", "第5章_提升影响力的几个细节/提升影响力的具体细节/四、定位.md"), + (10, "案例:咨询卡若", "第5章_提升影响力的几个细节/案例/咨询卡若 (目前为了拒绝一些没什么问题的用户,你花半小时解答,他花30秒看完).md"), + ]), + ("part-gps-6", "第六章、建造属于自己的游戏明星领地", "chapter-gps-6", "建造属于自己的游戏明星领地", [ + (1, "一、什么是老铁", "第6章_建造属于自己的游戏明星领地/一、什么是老铁.md"), + (2, "二、如何设置领地规则", "第6章_建造属于自己的游戏明星领地/二、如何设置领地规则.md"), + (3, "三、玩值电竞——你的游戏明星大本营", "第6章_建造属于自己的游戏明星领地/三、玩值电竞——你的游戏明星大本营.md"), + (4, "四、领地的商业模式设计", "第6章_建造属于自己的游戏明星领地/四、领地的商业模式设计.md"), + (5, "如何坚持下去", "第6章_建造属于自己的游戏明星领地/如何坚持下去.md"), + ]), + ("part-gps-appendix-a", "附录A、玩值电竞入门指南", "chapter-gps-appendix-a", "玩值电竞入门指南", [ + (1, "玩值电竞入门指南", "附录_玩值电竞入门指南/玩值电竞入门指南.md"), + ]), + ("part-gps-appendix-b", "附录B、干货工具箱", "chapter-gps-appendix-b", "干货工具箱", [ + (1, "吸粉话术模板库", "附录_干货工具箱/吸粉话术模板库.md"), + (2, "游戏明星日常运营SOP", "附录_干货工具箱/游戏明星日常运营SOP.md"), + (3, "游戏明星自检清单", "附录_干货工具箱/游戏明星自检清单.md"), + ]), +] + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL(保持 markdown 格式)""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓") + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def clean_excessive_blank_lines(md_text: str) -> str: + """压缩连续 3+ 空行为 2 空行,确保 markdown 解析正常""" + return re.sub(r"\n{3,}", "\n\n", md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片/代码块""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + cleaned = clean_excessive_blank_lines(cleaned) + html_out = markdown.markdown( + cleaned, + extensions=["tables", "fenced_code", "nl2br"], + ) + html_out = re.sub(r"\n{3,}", "\n\n", html_out) + return html_out.strip() + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql markdown requests") + + dry_run = "--dry-run" in sys.argv + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + print(f"\n 📄 {section_id} | {section_title}") + + try: + raw = md_path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, md_path, upload_cache, dry_run) + html_content = md_to_html(processed) + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML={len(html_content)}字符, 纯文字={word_count}") + has_table = "
Path | None: + if ref.startswith(("http://", "https://")): + return None + p = (md_path.parent / ref).expanduser().resolve() + if p.is_file() and p.suffix.lower() in IMAGE_EXT: + return p + return None + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + import requests + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def md_to_html(md_path: Path, upload_cache: dict[str, str], dry_run: bool) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + chunks.append(f'
') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"
(图片路径无效:{html.escape(ref)})
") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'') + continue + stripped = line.strip() + if stripped.startswith("##"): + heading = stripped.lstrip("#").strip() + chunks.append(f"
{html.escape(heading)}
") + elif stripped: + chunks.append(f"{html.escape(stripped)}
") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def has_image_refs(md_path: Path) -> bool: + text = md_path.read_text(encoding="utf-8") + return bool(re.search(r"^\s*!\[", text, re.MULTILINE)) + + +def plain_body(md_path: Path) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def ensure_book(cur, conn) -> int: + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + print(f"书已存在: book_id={row[0]}") + return row[0] + + cur.execute("SELECT MAX(sort_order) FROM books") + max_sort = cur.fetchone()[0] or 0 + + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, max_sort + 1, "published"), + ) + conn.commit() + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + book_id = cur.fetchone()[0] + print(f"已创建书: book_id={book_id}, key={BOOK_KEY}, title={BOOK_TITLE}") + return book_id + + +def upsert_section(cur, conn, book_id: int, section_id: str, part_id: str, part_title: str, + chapter_id: str, chapter_title: str, section_title: str, + content: str, sort_order: int, price: float): + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + "UPDATE chapters SET book_id=%s, section_title=%s, content=%s, " + "part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, " + "price=%s, sort_order=%s, word_count=%s, status=%s, " + "edition_standard=%s, edition_premium=%s WHERE id=%s", + (book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, sort_order, len(content), "published", + True, False, section_id), + ) + conn.commit() + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, book_id, section_title, content, " + "part_id, part_title, chapter_id, chapter_title, " + "price, is_free, sort_order, word_count, status, " + "edition_standard, edition_premium) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + (section_id, book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, False, sort_order, len(content), "published", + True, False), + ) + conn.commit() + return "created" + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + if not dry_run: + book_id = ensure_book(cur, conn) + else: + book_id = 0 + print("[dry-run] 跳过创建书") + + upload_cache: dict[str, str] = {} + global_sort = 0 + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f" part_id={part_id} chapter_id={chapter_id}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + global_sort += 1 + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + has_imgs = has_image_refs(md_path) + img_tag = "🖼" if has_imgs else " " + + try: + if has_imgs: + content = md_to_html(md_path, upload_cache, dry_run) + else: + content = plain_body(md_path) + except Exception as e: + print(f" ✗ 读取失败 {rel_path}: {e}") + total_err += 1 + continue + + if dry_run: + print(f" {img_tag} [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + continue + + try: + action = upsert_section( + cur, conn, book_id, section_id, + part_id, part_title, chapter_id, chapter_title, + section_title, content, global_sort, price, + ) + print(f" ✓ [{action}] [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/upload_solo_company_book.py b/scripts/upload_solo_company_book.py new file mode 100644 index 00000000..dff67a1c --- /dev/null +++ b/scripts/upload_solo_company_book.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +上传《一人公司——你能掌控的自由人生》到小程序。 + +book_key : solo-company +共 10 章 + 1 术语手册,分 4 个篇。 + +用法: + python3 scripts/upload_solo_company_book.py --dry-run # 预览 + python3 scripts/upload_solo_company_book.py # 执行上传 +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《一人公司》") + +BOOK_KEY = "solo-company" +BOOK_TITLE = "一人公司" +BOOK_SUBTITLE = "你能掌控的自由人生" +BOOK_EMOJI = "🏢" +BOOK_SORT_ORDER = 10 + +# 篇章结构定义 +PARTS = { + "part-solo-1": { + "title": "启程·方向", + "chapters": { + "chapter-solo-1": { + "title": "找准方向,别再瞎忙", + "sections": [1, 2, 3], + } + }, + }, + "part-solo-2": { + "title": "实战·方法", + "chapters": { + "chapter-solo-2": { + "title": "用对方法,高效变现", + "sections": [4, 5, 6], + } + }, + }, + "part-solo-3": { + "title": "进阶·系统", + "chapters": { + "chapter-solo-3": { + "title": "搭建系统,持续增长", + "sections": [7, 8, 9, 10], + } + }, + }, + "part-solo-appendix": { + "title": "附录", + "chapters": { + "ch-solo-appendix": { + "title": "术语手册", + "sections": ["glossary"], + } + }, + }, +} + +# 章节文件映射 +SECTION_FILES = { + 1: "1、你越努力越穷,是因为你连方向都没找对.md", + 2: "2、9个人干出千万业绩:一人公司到底怎么运转的.md", + 3: "3、别再找项目了——你自己就是最值钱的产品.md", + 4: "4、我从不追求原创,但我每篇内容都赚到了钱.md", + 5: "5、30个账号每天自动来客户——你也能复制这套打法.md", + 6: "6、你认识谁,比你会什么值钱10倍.md", + 7: "7、搭一台你睡着了也在赚钱的机器.md", + 8: "8、你赚多少钱,取决于你把自己当成什么人.md", + 9: "9、一天重启你的人生:我用了10年验证的方法.md", + 10: "10、别等准备好再开始——这游戏根本没有终点.md", + "glossary": "术语手册:这本书里的黑话全在这.md", +} + +# 章节标题(去掉序号前缀) +SECTION_TITLES = { + 1: "你越努力越穷,是因为你连方向都没找对", + 2: "9个人干出千万业绩:一人公司到底怎么运转的", + 3: "别再找项目了——你自己就是最值钱的产品", + 4: "我从不追求原创,但我每篇内容都赚到了钱", + 5: "30个账号每天自动来客户——你也能复制这套打法", + 6: "你认识谁,比你会什么值钱10倍", + 7: "搭一台你睡着了也在赚钱的机器", + 8: "你赚多少钱,取决于你把自己当成什么人", + 9: "一天重启你的人生:我用了10年验证的方法", + 10: "别等准备好再开始——这游戏根本没有终点", + "glossary": "术语手册:这本书里的黑话全在这", +} + +DB_CONFIG = { + "host": "56b4c23f6853c.gz.cdb.myqcloud.com", + "port": 14413, + "user": "cdb_outerroot", + "password": "Zhiqun1984", + "database": "soul_miniprogram", +} + + +def md_to_html(md_text: str) -> str: + """将 Markdown 转为美观的 HTML(适配小程序 contentParser)""" + lines = md_text.splitlines() + # 去掉第一行 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + html_parts: list[str] = [] + i = 0 + + def inline_format(text: str) -> str: + """处理行内格式:粗体、斜体、行内代码、链接""" + # 粗体+斜体 + text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text) + # 粗体 + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + # 斜体 + text = re.sub(r"(?\1", text) + # 行内代码 + text = re.sub(r"`(.+?)`", r"\1", text) + # 链接 + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text) + return text + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # 空行跳过 + if not stripped: + i += 1 + continue + + # 图片 → 跳过(小程序不支持外链图片) + if re.match(r"^!\[.*\]\(.*\)$", stripped): + i += 1 + continue + + # 水平线 + if stripped == "---": + html_parts.append("
") + i += 1 + continue + + # h2 + if stripped.startswith("## "): + heading_text = inline_format(stripped[3:].strip()) + html_parts.append(f"{heading_text}
") + i += 1 + continue + + # h3 + if stripped.startswith("### "): + heading_text = inline_format(stripped[4:].strip()) + html_parts.append(f"{heading_text}
") + i += 1 + continue + + # 引用块 + if stripped.startswith("> "): + quote_lines = [] + while i < len(lines) and lines[i].strip().startswith("> "): + quote_lines.append(inline_format(lines[i].strip()[2:].strip())) + i += 1 + html_parts.append(f"{'") + continue + + # 待办列表 - [ ] / - [x] + if re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines) and re.match(r"^- \[[ x]\] ", lines[i].strip()): + s = lines[i].strip() + checked = s[3] == "x" + text = inline_format(s[6:].strip()) + check_mark = "✅" if checked else "☐" + items.append(f"
'.join(quote_lines)}- {check_mark} {text}
") + i += 1 + html_parts.append(f"{''.join(items)}
") + continue + + # 有序列表 + if re.match(r"^\d+\.\s+", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^\d+\.\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"- {text}
") + i += 1 + # 如果下一行是缩进的续行,合并进当前 li + while i < len(lines) and lines[i].strip() and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("- ") and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">") and lines[i].startswith(" "): + items[-1] = items[-1].replace("", f"
{inline_format(lines[i].strip())}") + i += 1 + html_parts.append(f"{''.join(items)}
") + continue + + # 无序列表(含多级缩进 *) + if re.match(r"^[-*]\s+", stripped) and not re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^[-*]\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"- {text}
") + i += 1 + # 缩进续行 + while i < len(lines) and lines[i].strip() and not re.match(r"^[-*]\s+", lines[i].strip()) and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">"): + if lines[i].startswith(" ") or lines[i].startswith("\t"): + sub = lines[i].strip() + sub_m = re.match(r"^[-*]\s+(.+)", sub) + if sub_m: + items[-1] = items[-1].replace("", f"
• {inline_format(sub_m.group(1))}") + else: + items[-1] = items[-1].replace("", f"
{inline_format(sub)}") + i += 1 + else: + break + html_parts.append(f"{''.join(items)}
") + continue + + # 普通段落 + para_lines = [inline_format(stripped)] + i += 1 + html_parts.append(f"{para_lines[0]}
") + continue + + return "\n".join(html_parts) + + +def find_section_location(section_key) -> tuple[str, str, str, str]: + """根据 section key 找到对应的 part/chapter 信息""" + for part_id, part_info in PARTS.items(): + for ch_id, ch_info in part_info["chapters"].items(): + if section_key in ch_info["sections"]: + return part_id, part_info["title"], ch_id, ch_info["title"] + return "part-solo-1", "启程·方向", "chapter-solo-1", "找准方向,别再瞎忙" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price_free = 0.0 + price_paid = 1.0 + + conn = pymysql.connect(**DB_CONFIG) + cur = conn.cursor() + + # ── Step 1: 确保书籍存在 ── + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + book_id = row[0] + print(f"📖 书籍已存在: id={book_id}, key={BOOK_KEY}") + if not dry_run: + cur.execute( + "UPDATE books SET title=%s, subtitle=%s, icon_emoji=%s, sort_order=%s, status='published' WHERE id=%s", + (BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER, book_id), + ) + conn.commit() + else: + if dry_run: + print(f"📖 [dry] 将创建书籍: {BOOK_KEY} - {BOOK_TITLE}") + book_id = 999 + else: + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) VALUES (%s, %s, %s, %s, %s, 'published')", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER), + ) + conn.commit() + book_id = cur.lastrowid + print(f"📖 已创建书籍: id={book_id}, key={BOOK_KEY}") + + # ── Step 2: 上传各章节 ── + total_ok = 0 + total_err = 0 + sort_order = 0 + + for section_key in list(range(1, 11)) + ["glossary"]: + filename = SECTION_FILES[section_key] + title = SECTION_TITLES[section_key] + section_id = f"solo-{section_key}" if isinstance(section_key, int) else "solo-glossary" + part_id, part_title, chapter_id, chapter_title = find_section_location(section_key) + + filepath = BOOK_DIR / filename + if not filepath.exists(): + print(f" ⚠ 文件不存在: {filepath}") + total_err += 1 + continue + + raw = filepath.read_text(encoding="utf-8") + html_content = md_to_html(raw) + word_count = len(re.sub(r"<[^>]+>", "", html_content)) + + is_free = section_key in [1, "glossary"] + price = price_free if is_free else price_paid + + if dry_run: + preview = html_content[:80].replace("\n", " ") + print(f" [dry] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字 | {'免费' if is_free else '付费'} | {preview}…") + total_ok += 1 + sort_order += 1 + continue + + try: + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + """UPDATE chapters SET + book_id=%s, section_title=%s, content=%s, word_count=%s, + part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, + price=%s, is_free=%s, sort_order=%s, status='published', + edition_standard=1, edition_premium=0 + WHERE id=%s""", + (book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, section_id), + ) + conn.commit() + print(f" ✓ [updated] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + else: + cur.execute( + """INSERT INTO chapters + (id, book_id, section_title, content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, status, edition_standard, edition_premium) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published', 1, 0)""", + (section_id, book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order), + ) + conn.commit() + print(f" ✓ [created] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + sort_order += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + else: + print("✅ 上传完毕!请在管理端或小程序验证。") + + +if __name__ == "__main__": + main() diff --git a/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx b/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx index 13a27f9b..3ac56ad4 100644 --- a/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx +++ b/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx @@ -500,9 +500,9 @@ export const CkbLeadsPanel = forwardRefconst pushStatusBadge = (status?: string) => { const s = status || '' if (s === 'success') - return 已推送(存客宝已接收) + return已推送 if (s === 'pending_verify') - return待通过 / 处理中 + return处理中 if (s === 'expired') return已过期 if (s === 'failed') return失败 if (s === 'pending') @@ -586,12 +586,12 @@ export const CkbLeadsPanel = forwardRef{!leadsLoading && ( -线索总条数(含留资/加入/匹配)
+线索总数
{leadsTotal}
-@@ -600,7 +600,7 @@ export const CkbLeadsPanel = forwardRef去重用户数(按 userId)
-+
去重用户
+{leadsStats.uniqueUsers ?? 0}
: [] ).map((s) => ( -))} @@ -610,10 +610,7 @@ export const CkbLeadsPanel = forwardRef来源:{s.source}
+{s.source}
{s.cnt}
--+失败原因聚合
-基于当前页筛选结果,按失败原因聚合统计
-失败聚合