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):从 `

`~`

` 提取,用占位符 `__H_N__` 暂存 +- `quote`:从 `
` 提取 +- `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 @@ - - + + @@ -27,129 +27,142 @@ - - - - - - - - - - - - {{chaptersBookTitle}} - {{chaptersBookSubtitle}} - - - - {{totalSections}} - 章节 - - {{bookCollapsed ? '展开 ▸' : '折叠 ▾'}} - - + + - - - - - - - 序言|为什么我每天早上6点在Soul开播? - - - 免费 - - - - - - - - - - - - {{item.iconText}} - {{item.iconEmoji}} - {{item.title[0] || '篇'}} - - {{item.title}} - {{item.subtitle}} - + + + + + + + {{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}} + + + + + + + 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 @@ + + + + 收益排行榜 + + + + + + + {{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 @@ 快捷入口 - + 订单 @@ -110,6 +110,11 @@ {{walletBalanceText}} 我的余额 + + + 客资 + 链接与轨迹 + 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 @@ @@ -159,38 +159,44 @@ - - - + + + + - - 解锁完整内容,分享得到 - {{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) + '% 收益')}} @@ -245,47 +251,51 @@ - - - + + + + - - 解锁完整内容,分享得到 - {{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) + '% 收益')}} @@ -486,4 +496,14 @@ + + + + {{pay365AnchorLabel}} + + 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 @@ + + + + + + 链接客资 + + + + + + 绑定人物:{{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}} + + 加载中... + 上拉加载更多 + + 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 } +/** + * 解析

        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'

        {html.escape(alt)}

        ') + 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'

        {html.escape(alt)}

        ') + 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"![{alt}]({upload_cache[key]})" + 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*

        ([\s\S]*?)

        \s*
      • ", + r"
      • \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"
          • {item.strip()}
          • ") + parts.append("
          ") + return "\n".join(parts) + + html = re.sub( + r"

          (.*?)\n((?:\s*-\s+.+\n?)+)

          ", + fix_inline_list, + html, + flags=re.IGNORECASE, + ) + + # 压缩 block 标签之间的空白:\n\n\n + html = re.sub( + r"()\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"]*>", "\n", 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"]*>", "\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"![{alt}]({upload_cache[key]})" + 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"![{alt}]({upload_cache[key]})" + 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'

        {html.escape(alt)}

        ') + 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'

        {html.escape(alt)}

        ') + 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"
        {'
        '.join(quote_lines)}
        ") + 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"
      • {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 = forwardRef const 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}

        -

        去重用户数(按 userId)

        -

        +

        去重用户

        +

        {leadsStats.uniqueUsers ?? 0}

        @@ -600,7 +600,7 @@ export const CkbLeadsPanel = forwardRef : [] ).map((s) => (
        -

        来源:{s.source}

        +

        {s.source}

        {s.cnt}

        ))} @@ -610,10 +610,7 @@ export const CkbLeadsPanel = forwardRef
        -
        -

        失败原因聚合

        -

        基于当前页筛选结果,按失败原因聚合统计

        -
        +

        失败聚合

        {!leadsLoading && leadsRows.length > 0 && (

        - 已选 {leadSelectedIds.length} 条 · 可翻页继续勾选 · - 改搜索/筛选会清空选择 + 已选 {leadSelectedIds.length} 条 {leadSelectedIds.length > 0 && ( - - - -

        -

        选择风格后保存,无头像用户按 MBTI 自动匹配。前端用户也可在个人资料中选择。

        +

        + 头像文件由 soul-api 静态目录提供,路径形如 /static/mbti-avatars-png-male/INTJ.png + 。网格未配置时默认预览男版 PNG;「自动分配」按 localStorage 键 {LS_USER_GENDER_PACK}{' '} + (male / female,未设则为男版)写入 16 型 URL。图片加载失败时显示类型缩写占位。 +

        {MBTI_TYPES_ORDERED.map((t) => { - const url = avatars[t] ?? '' - const meta = MBTI_AVATAR_PROFILES[t] + const stored = (avatars[t] ?? '').trim() + const displayPath = stored || staticPngPathForType(t, 'male') + const src = mbtiAvatarImgSrc(displayPath) + const showPlaceholder = !src || imgBroken[t] + return ( -
        +
        - {url ? {t} { (e.target as HTMLImageElement).style.display = 'none' }} /> : 未配} + {showPlaceholder ? ( + + {t} + + ) : ( + {t} setImgBroken((prev) => ({ ...prev, [t]: true }))} + onLoad={(e) => { + const el = e.target as HTMLImageElement + if (el.naturalWidth <= 0) setImgBroken((prev) => ({ ...prev, [t]: true })) + }} + /> + )}
        {t} - {meta.nickname} + + {MBTI_AVATAR_PROFILES[t].nickname} +
        ) })} diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index 899d555b..d99a8704 100644 --- a/soul-admin/src/components/modules/user/UserDetailModal.tsx +++ b/soul-admin/src/components/modules/user/UserDetailModal.tsx @@ -1,6 +1,6 @@ import toast from '@/utils/toast' import { normalizeImageUrl } from '@/lib/utils' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { Dialog, DialogContent, @@ -57,6 +57,8 @@ interface UserDetail { earnings?: number pendingEarnings?: number referralCount?: number + referralClickCount?: number + referralBindCount?: number createdAt?: string updatedAt?: string tags?: string @@ -219,6 +221,9 @@ export function UserDetailModal({ const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState>({}) const [purchaseList, setPurchaseList] = useState<{ orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[]>([]) + /** 防止切换用户或重复打开时,较慢的旧请求覆盖新用户的详情/轨迹 */ + const detailLoadGenRef = useRef(0) + useEffect(() => { if (open && userId) { setAvatarBroken(false) @@ -228,6 +233,12 @@ export function UserDetailModal({ setBatchIngestResult(null) setNewPassword('') setConfirmPassword('') + setTracks([]) + setTrackStats({}) + setReferrals([]) + setInboundSource(null) + setBalanceData(null) + setPurchaseList([]) loadUserDetail() get<{ success?: boolean; data?: { id: number; name: string }[] }>('/api/db/vip-roles').then((r) => { if (r?.success && (r as { data?: { id: number; name: string }[] }).data) setVipRoles((r as { data: { id: number; name: string }[] }).data) @@ -255,11 +266,14 @@ export function UserDetailModal({ async function loadUserDetail() { if (!userId) return + const gen = ++detailLoadGenRef.current + const uid = userId setLoading(true) try { const userData = await get<{ success?: boolean; user?: UserDetail }>( - `/api/db/users?id=${encodeURIComponent(userId)}`, + `/api/db/users?id=${encodeURIComponent(uid)}`, ) + if (gen !== detailLoadGenRef.current) return if (userData?.success && userData.user) { const u = userData.user setUser(u) @@ -283,12 +297,15 @@ export function UserDetailModal({ vipContact: String(u.vipContact ?? ''), vipBio: String(u.vipBio ?? ''), }) + } else { + setUser(null) } // 行为轨迹(用户旅程) try { const trackData = await get<{ success?: boolean; tracks?: UserTrack[]; stats?: Record }>( - `/api/admin/user/track?userId=${encodeURIComponent(userId)}&limit=100`, + `/api/admin/user/track?userId=${encodeURIComponent(uid)}&limit=100`, ) + if (gen !== detailLoadGenRef.current) return if (trackData?.success) { setTrackStats(trackData.stats && typeof trackData.stats === 'object' ? trackData.stats : {}) const list = trackData.tracks || [] @@ -304,14 +321,16 @@ export function UserDetailModal({ setTracks([]) } } catch { + if (gen !== detailLoadGenRef.current) return setTrackStats({}) setTracks([]) } // 关系链路 try { const refData = await get<{ success?: boolean; referrals?: unknown[]; inboundSource?: InboundSourceData }>( - `/api/db/users/referrals?userId=${encodeURIComponent(userId)}`, + `/api/db/users/referrals?userId=${encodeURIComponent(uid)}`, ) + if (gen !== detailLoadGenRef.current) return if (refData?.success) { setReferrals(refData.referrals || []) setInboundSource(refData.inboundSource || null) @@ -320,27 +339,38 @@ export function UserDetailModal({ setInboundSource(null) } } catch { + if (gen !== detailLoadGenRef.current) return setReferrals([]) setInboundSource(null) } try { const balData = await get<{ success?: boolean; data?: { balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } }>( - `/api/admin/users/${encodeURIComponent(userId)}/balance`, + `/api/admin/users/${encodeURIComponent(uid)}/balance`, ) + if (gen !== detailLoadGenRef.current) return if (balData?.success && balData.data) setBalanceData(balData.data) else setBalanceData(null) - } catch { setBalanceData(null) } + } catch { + if (gen !== detailLoadGenRef.current) return + setBalanceData(null) + } try { const ordersData = await get<{ success?: boolean; orders?: { orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[] }>( - `/api/admin/orders?userId=${encodeURIComponent(userId)}&status=paid&pageSize=50`, + `/api/admin/orders?userId=${encodeURIComponent(uid)}&status=paid&pageSize=50`, ) + if (gen !== detailLoadGenRef.current) return if (ordersData?.success && ordersData.orders) setPurchaseList(ordersData.orders) else setPurchaseList([]) - } catch { setPurchaseList([]) } + } catch { + if (gen !== detailLoadGenRef.current) return + setPurchaseList([]) + } } catch (e) { console.error('Load user detail error:', e) } finally { - setLoading(false) + if (gen === detailLoadGenRef.current) { + setLoading(false) + } } } @@ -593,12 +623,15 @@ export function UserDetailModal({ if (!open) return null + const sectionTitleClass = 'text-sm font-bold text-white mb-3 flex items-center gap-2' + const cardClass = 'rounded-xl bg-[#0a1628] border border-gray-700/50 p-4' + return ( <> onClose()}> - - - + + + 用户详情 {user?.phone && 已绑定手机} @@ -612,254 +645,234 @@ export function UserDetailModal({ 加载中...
        ) : user ? ( -
        - {/* 头部:身份 + 收益(紧凑) */} -
        -
        -
        - {resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? ( - setAvatarBroken(true)} - /> - ) : ( - user.nickname?.charAt(0) || '?' - )} -
        -
        -
        -

        {user.nickname}

        - {user.isAdmin && 管理员} - {user.hasFullBook && 全书已购} - {user.vipRole && {user.vipRole}} +
        + {/* 顶部:紧凑身份栏 + 财务概览 */} +
        +
        +
        +
        + {resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? ( + setAvatarBroken(true)} + /> + ) : ( + user.nickname?.charAt(0) || '?' + )}
        - {user.referralCode && ( -

        - 推荐码 {user.referralCode} -

        - )} -
        -
        - 昵称 -

        {editNickname || user.nickname || '—'}

        +
        +
        +

        {user.nickname}

        + {user.isAdmin && 管理员} + {user.hasFullBook && 全书已购} + {user.vipRole && {user.vipRole}} + {user.mbti && {user.mbti}}
        -
        - 手机号 -

        {editPhone || '—'}

        -
        -
        - 微信标识 -

        {editWechatId || '—'}

        -
        -
        - 画像 -

        - {[user.region, user.industry, user.position, user.mbti ? `MBTI ${user.mbti}` : ''].filter(Boolean).join(' · ') || '未完善'} -

        +
        + {editPhone && {editPhone}} + {editWechatId && {editWechatId}} + {user.createdAt ? new Date(user.createdAt).toLocaleDateString('zh-CN') : '—'} + {user.referralCode && 推荐码 {user.referralCode}}
        -
        -
        -
        -

        累计佣金

        -

        ¥{(user.earnings ?? 0).toFixed(2)}

        -

        推广/分佣入账

        -
        -
        -

        待提现

        -

        ¥{(user.pendingEarnings ?? 0).toFixed(2)}

        -

        未打款部分

        -
        -
        -
        -

        账户余额

        - +
        +
        +

        累计佣金

        +

        ¥{(user.earnings ?? 0).toFixed(2)}

        +
        +
        +

        待提现

        +

        ¥{(user.pendingEarnings ?? 0).toFixed(2)}

        +
        +
        +
        +

        账户余额

        + +
        +

        ¥{(balanceData?.balance ?? 0).toFixed(2)}

        +
        +
        +

        链接点击

        +

        {user.referralClickCount ?? 0}

        +
        +
        +

        绑定/转化

        +

        {user.referralBindCount ?? user.referralCount ?? 0}

        -

        ¥{(balanceData?.balance ?? 0).toFixed(2)}

        -

        可消费/抵扣

        -
        -
        -

        推荐人数

        -

        {user.referralCount ?? 0}

        -

        {user.createdAt ? `注册 ${new Date(user.createdAt).toLocaleDateString()}` : '—'}

        - - - + + + 用户信息 - - + + 旅程与轨迹 - + 关系链路 - + 标签体系 - {/* ===== 用户信息(紧凑单屏):基础字段 + 超级个体 + 外部同步合并为一屏滚动 ===== */} - -
        - - 技术标识 - (用户ID / OpenID,默认折叠) - -
        -

        - 用户ID {user.id} -

        -

        - OpenID {user.openId || '—'} -

        -

        - OpenID 为微信用户标识;下方「微信标识」为微信号/wxid,供存客宝归属,与 OpenID 不同。 -

        -
        -
        -
        -
        - - setEditNickname(e.target.value)} /> -
        -
        - - setEditPhone(e.target.value)} /> -
        -
        - - setEditWechatId(e.target.value)} /> + {/* ===== 用户信息:基础字段 + 超级个体 + 外部同步 ===== */} + +
        +

        + + 编辑资料 +

        +
        +
        + + setEditNickname(e.target.value)} /> +
        +
        + + setEditPhone(e.target.value)} /> +
        +
        + + setEditWechatId(e.target.value)} /> +
        + {(user.region || user.industry || user.position || user.mbti) && ( +
        + {user.region && ( + + {user.region} + + )} + {user.industry && 行业 {user.industry}} + {user.position && 职位 {user.position}} + {user.mbti && MBTI {user.mbti}} +
        + )}
        - {(user.region || user.industry || user.position || user.mbti) && ( -
        - {user.region && ( - - - {user.region} - - )} - {user.industry && 行业 {user.industry}} - {user.position && 职位 {user.position}} - {user.mbti && MBTI {user.mbti}} +
        + + 技术标识 + 用户ID / OpenID + +
        +

        用户ID {user.id}

        +

        OpenID {user.openId || '—'}

        - )} +
        -
        +
        -
        - - 超级个体 +
        + + 超级个体 {user.isVip && {user.vipRole || 'VIP'}}
        setVipForm((f) => ({ ...f, isVip: c }))} />
        {vipForm.isVip && ( -
        -
        - - setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))} /> +
        +
        + + setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))} />
        -
        - - setVipForm((f) => ({ ...f, vipRole: e.target.value }))}> - {vipRoles.map((r) => ( - - ))} + {vipRoles.map((r) => )}
        -
        - - setVipForm((f) => ({ ...f, vipName: e.target.value }))} /> +
        + + setVipForm((f) => ({ ...f, vipName: e.target.value }))} />
        -
        - - setVipForm((f) => ({ ...f, vipProject: e.target.value }))} /> +
        + + setVipForm((f) => ({ ...f, vipProject: e.target.value }))} />
        -
        - - setVipForm((f) => ({ ...f, vipContact: e.target.value }))} /> +
        + + setVipForm((f) => ({ ...f, vipContact: e.target.value }))} />
        -
        - - setVipForm((f) => ({ ...f, vipBio: e.target.value }))} /> +
        + + setVipForm((f) => ({ ...f, vipBio: e.target.value }))} />
        )}
        -
        -
        - - 外部资料 · 神射手 / 存客宝(与上方基础信息联动) +
        +

        + + 外部资料 · 神射手 +

        +
        + setSssQueryPhone(e.target.value)} /> + setSssQueryWechatId(e.target.value)} /> + setSssQueryOpenId(e.target.value)} />
        -
        - setSssQueryPhone(e.target.value)} /> - setSssQueryWechatId(e.target.value)} /> - setSssQueryOpenId(e.target.value)} /> -
        -
        - - -
        - {user.ckbSyncedAt &&

        最近存客宝同步:{new Date(user.ckbSyncedAt).toLocaleString()}

        } - {sssError &&

        {sssError}

        } + {user.ckbSyncedAt &&

        存客宝同步:{new Date(user.ckbSyncedAt).toLocaleString()}

        } + {sssError &&

        {sssError}

        } {sssData && ( -
        -
        - RFM {sssData.rfm_score ?? '—'} +
        +
        + RFM + {sssData.rfm_score ?? '—'}
        -
        - 等级 {sssData.user_level ?? '—'} +
        + 等级 + {sssData.user_level ?? '—'}
        )} {batchIngestResult && ( -

        {batchIngestResult.error ? {String(batchIngestResult.error)} : 推送成功}

        +

        {batchIngestResult.error ? {String(batchIngestResult.error)} : 推送成功}

        )}
        -
        -
        - - 修改密码 -
        -
        - setNewPassword(e.target.value)} /> - setConfirmPassword(e.target.value)} /> -
        @@ -867,191 +880,206 @@ export function UserDetailModal({ {/* ===== 用户旅程 + 行为轨迹 ===== */} - + {purchaseList.length > 0 && ( -
        -
        - - 购买清单({purchaseList.length} 笔) +
        +
        +

        + + 购买记录 +

        + 共 {purchaseList.length} 笔
        -
        +
        {purchaseList.map((o, i) => ( -
        +
        - - {o.productType === 'fullbook' || o.productType === 'vip' ? '全书/VIP' : `章节 ${o.productId || ''}`} + + {o.productType === 'fullbook' || o.productType === 'vip' ? '读书会/VIP' : `章节 ${o.productId || ''}`} ¥{Number(o.amount || 0).toFixed(2)}
        - {o.createdAt ? new Date(o.createdAt).toLocaleString('zh-CN') : ''} + {o.createdAt ? new Date(o.createdAt).toLocaleString('zh-CN') : ''}
        ))}
        )} -
        -
        - - 全站埋点共 {tracks.length} 条;用于 RFM 与「标签体系」旅程推断 -
        - {Object.keys(trackStats).length > 0 && ( -
        +
        +

        + + 行为统计 +

        +

        全站埋点共 {tracks.length} 条;用于 RFM 与「标签体系」旅程推断。

        + {Object.keys(trackStats).length > 0 ? ( +
        {Object.entries(trackStats) .sort((a, b) => b[1] - a[1]) .map(([act, n]) => ( - + {labelTrackAction(act)} ×{n} ))}
        + ) : ( +

        暂无统计数据

        )}
        -
        - {tracks.length > 0 ? ( - tracks.map((track, idx) => ( -
        -
        -
        {getActionIcon(track.action)}
        - {idx < tracks.length - 1 &&
        } -
        -
        -
        - {track.actionLabel || track.action} - {track.moduleLabel && · {track.moduleLabel}} - {track.chapterTitle && · {track.chapterTitle}} +
        +

        + + 行为轨迹 +

        +
        + {tracks.length > 0 ? ( + tracks.map((track, idx) => ( +
        +
        +
        {getActionIcon(track.action)}
        + {idx < tracks.length - 1 &&
        } +
        +
        +
        + {track.actionLabel || track.action} + {track.moduleLabel && · {track.moduleLabel}} + {track.chapterTitle && · {track.chapterTitle}} +
        + {track.target && + track.target !== track.chapterTitle && + !trackTargetIsOpaqueId(track.target) && ( +

        详情: {track.target}

        + )} +

        + + {track.timeAgo ? `${track.timeAgo} · ` : ''} + {track.createdAt ? new Date(track.createdAt).toLocaleString() : ''} +

        - {track.target && - track.target !== track.chapterTitle && - !trackTargetIsOpaqueId(track.target) && ( -

        详情: {track.target}

        - )} -

        - - {track.timeAgo ? `${track.timeAgo} · ` : ''} - {track.createdAt ? new Date(track.createdAt).toLocaleString() : ''} -

        + )) + ) : ( +
        + +

        暂无旅程记录

        - )) - ) : ( -
        - - 暂无旅程记录 -
        - )} + )} +
        {/* ===== 关系链路 ===== */} - -
        -
        -
        - - 入站关系链路 -
        - + +
        +
        +

        + + 入站关系链路 +

        + 点击 {inboundSource?.totalVisits || 0} 次
        -
        -
        -

        首次来自

        -

        +

        +
        +

        首次来自

        +

        {inboundSource?.firstVisit?.referrerNickname || '—'} {inboundSource?.firstVisit?.referrerId ? `(${inboundSource.firstVisit.referrerId})` : ''}

        -
        -

        最近来自

        -

        +

        +

        最近来自

        +

        {inboundSource?.latestVisit?.referrerNickname || '—'} {inboundSource?.latestVisit?.referrerId ? `(${inboundSource.latestVisit.referrerId})` : ''}

        {inboundSource?.activeBinding?.referrerId ? ( -
        -

        +

        +

        当前绑定: {inboundSource.activeBinding.referrerNickname || '微信用户'} {`(${inboundSource.activeBinding.referrerId})`}

        ) : null} -
        +

        来源点击记录

        +
        {(inboundSource?.visits || []).length > 0 ? ( (inboundSource?.visits || []).map((v, i) => ( -
        +

        第 {v.seq || i + 1} 次 · {v.referrerNickname || '微信用户'} {v.referrerId ? `(${v.referrerId})` : ''}

        - {v.page ?

        {v.page}

        : null} + {v.page ?

        {v.page}

        : null}
        - + {v.visitedAt ? new Date(v.visitedAt).toLocaleString() : ''}
        )) ) : ( -

        暂无来源点击记录

        +

        暂无来源点击记录

        )}
        -
        -
        -
        - - 推荐的用户 -
        - 共 {referrals.length} 人 +
        +
        +

        + + 推荐信息 +

        + 共 {referrals.length} 人
        -
        +
        {referrals.length > 0 ? ( referrals.map((ref: unknown, i: number) => { const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string } return ( -
        -
        -
        {r.nickname?.charAt(0) || '?'}
        - {r.nickname} +
        +
        +
        {r.nickname?.charAt(0) || '?'}
        + {r.nickname}
        -
        +
        {r.status === 'vip' && 已购} - {r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''} + {r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}
        ) }) ) : ( -

        暂无推荐用户

        +

        暂无推荐用户

        )}
        {/* ===== 标签体系 ===== */} - -
        -
        - - 用户标签 - 《一场 Soul 的创业实验》维度 + +
        +
        +

        + + 用户标签 +

        + 《一场 Soul 的创业实验》维度
        -
        - - 预设可点选;下方「旅程推断」由轨迹+资料自动算出,可一键并入已选后点弹窗底部保存。 +
        + + 预设可点选;下方「旅程推断」由轨迹与资料自动算出,可一键并入已选后点弹窗底部保存。
        -
        -
        - 旅程推断标签 +
        +
        + 旅程推断标签
        {journeyInferredTags.length > 0 ? ( -
        +
        {journeyInferredTags.map((t) => ( {editTags.includes(t) ? '✓ ' : ''} {t} @@ -1072,10 +1100,10 @@ export function UserDetailModal({ ))}
        ) : ( -

        暂无推断(无轨迹或行为未命中规则)

        +

        暂无推断(无轨迹或行为未命中规则)

        )}
        -
        +
        {[ { category: '身份类型', @@ -1099,8 +1127,8 @@ export function UserDetailModal({ }, ].map((group) => (
        -

        {group.category}

        -
        +

        {group.category}

        +
        {group.tags.map((tag) => (
        -
        -

        已选标签(需保存修改写入库)

        -
        +
        +

        已选标签(需保存修改写入库)

        +
        {editTags.map((tag, i) => ( - + {tag} ))} - {editTags.length === 0 && 暂未选择} + {editTags.length === 0 && 暂未选择}
        -
        +
        setNewTag(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTag()} /> -
        @@ -1162,14 +1190,14 @@ export function UserDetailModal({ const tags = arr.map((t) => String(t).trim()).filter(Boolean) if (tags.length === 0) return null return ( -
        -
        - - 存客宝标签 -
        -
        +
        +

        + + 存客宝标签 +

        +
        {tags.map((tag, i) => ( - + {tag} ))} @@ -1181,18 +1209,12 @@ export function UserDetailModal({ -
        - -
        diff --git a/soul-admin/src/lib/ckbWorkbench.ts b/soul-admin/src/lib/ckbWorkbench.ts new file mode 100644 index 00000000..a114f8da --- /dev/null +++ b/soul-admin/src/lib/ckbWorkbench.ts @@ -0,0 +1,49 @@ +/** + * 存客宝工作台:路由与接口说明(管理端 soul-admin 专用,不含小程序路径)。 + */ + +/** 与 FindPartnerPage 联动:带此参数进入页面时自动打开「存客宝工作台」弹层 */ +export const CKB_WORKBENCH_QUERY_KEY = 'ckb' +export const CKB_WORKBENCH_QUERY_VALUE = '1' + +export function ckbWorkbenchHref(): string { + return `/find-partner?${CKB_WORKBENCH_QUERY_KEY}=${CKB_WORKBENCH_QUERY_VALUE}` +} + +/** + * 与 soul-api 路由对齐的说明文本,供 CKBConfigPanel「API 文档」与概览引用。 + * 管理端仅应调用 admin/db 及公开的 /api/ckb/*(带管理员 Bearer 时与后台同源)。 + */ +export const CKB_ADMIN_API_REFERENCE = `# Soul 管理端 · 存客宝相关接口 + +## 一、场景获客(留资 / 加入 / 匹配上报) +- POST /api/ckb/join + - Body: type=partner|investor|mentor|team, phone 或 wechat 至少其一, userId, name 等 + - 用途:找伙伴各场景表单、管理端「匹配记录」里推送到存客宝 +- POST /api/ckb/match + - Body: matchType, phone/wechat, userId, nickname, matchedUser + - 用途:匹配成功上报存客宝场景 +- GET/POST /api/ckb/sync + - 当前后端实现为占位(返回 success),用户详情里「存客宝同步」走此路径 + +## 二、管理端开放 API 辅助(需 Admin JWT) +- GET /api/admin/ckb/devices — 存客宝设备列表(链接人与事选设备) +- GET /api/admin/ckb/plans — 获客计划列表 +- GET /api/admin/ckb/plan-check — 计划连通性探测 + +## 三、数据与配置(需 Admin JWT) +- GET /api/db/ckb-leads — 线索列表(mode=submitted|contact 等) +- GET /api/db/ckb-person-leads — 按人物 token 获客明细 +- GET/POST /api/db/config — key=ckb_config 存工作台场景路由等 + +## 四、直连存客宝 HTTP(由后端转发或开放 API) +- 场景获客:POST https://ckbapi.quwanzhi.com/v1/api/scenarios(签名规则见存客宝文档) +- 开放 API 基座:https://ckbapi.quwanzhi.com/v1/open/auth/token 等(人物计划创建在服务端 ckb_open.go) + +## 五、不在管理端调用的路径 +- /api/miniprogram/ckb/* 仅供小程序;后台测试「链接卡若」请用小程序或在此填写说明,勿在管理端请求该组路由。 + +## 六、章节与「内容库」 +- 章节写入:PUT /api/db/book(本书数据由库内维护) +- GET/POST /api/book/sync 当前为占位文案「同步由 DB 维护」 +- 若需同步到存客宝侧「内容库」,请在存客宝后台或运维脚本执行;保存章节后可在本工作台查看说明与入口。` diff --git a/soul-admin/src/lib/mbtiAvatarPrompts.ts b/soul-admin/src/lib/mbtiAvatarPrompts.ts index e7306431..e3f14ccf 100644 --- a/soul-admin/src/lib/mbtiAvatarPrompts.ts +++ b/soul-admin/src/lib/mbtiAvatarPrompts.ts @@ -54,80 +54,3 @@ export const MBTI_AVATAR_PROFILES: Record = { ESFP: { title: '表演者', nickname: '沙锤姐', group: 'SP', mood: 'playful' }, } -function paletteByGroup(group: MbtiGroup) { - switch (group) { - case 'NT': - return { bg: '#0d1424', body: '#c89a2c', accent: '#ffd66b', hair: '#6d540f', line: '#111827' } - case 'NF': - return { bg: '#0a1721', body: '#2e9f7c', accent: '#84e9c9', hair: '#2d6a4f', line: '#11212a' } - case 'SJ': - return { bg: '#101828', body: '#4f8cb8', accent: '#9bd4ff', hair: '#2e4a66', line: '#111f2d' } - case 'SP': - return { bg: '#161225', body: '#8b6bc0', accent: '#ccb3ff', hair: '#574183', line: '#211832' } - default: - return { bg: '#0e1422', body: '#38bdac', accent: '#7ee7db', hair: '#1f6f66', line: '#10202d' } - } -} - -function faceByMood(mood: MbtiAvatarMood): { eye: string; brow: string; mouth: string; tilt: number } { - switch (mood) { - case 'sharp': - return { eye: 'M222 222 L242 220 M270 220 L290 222', brow: 'M218 210 L244 202 M268 202 L294 210', mouth: 'M234 256 Q256 246 278 256', tilt: -5 } - case 'warm': - return { eye: 'M222 224 Q232 230 242 224 M270 224 Q280 230 290 224', brow: 'M220 210 Q232 206 244 210 M268 210 Q280 206 292 210', mouth: 'M232 254 Q256 272 280 254', tilt: 2 } - case 'playful': - return { eye: 'M222 224 Q232 236 242 224 M270 224 Q280 236 290 224', brow: 'M220 210 Q234 200 246 208 M266 208 Q278 200 292 210', mouth: 'M232 256 Q256 266 280 250', tilt: 8 } - default: - return { eye: 'M222 224 Q232 220 242 224 M270 224 Q280 220 290 224', brow: 'M220 210 Q232 208 244 210 M268 210 Q280 208 292 210', mouth: 'M236 256 Q256 260 276 256', tilt: 0 } - } -} - -function shouldersByMood(mood: MbtiAvatarMood): string { - switch (mood) { - case 'sharp': - return 'M168 370 L206 300 L256 332 L306 300 L344 370 L306 392 L256 374 L206 392 Z' - case 'warm': - return 'M166 368 Q188 318 226 314 L256 340 L286 314 Q324 318 346 368 L314 392 Q286 404 256 396 Q226 404 198 392 Z' - case 'playful': - return 'M164 370 L198 304 L252 332 L318 300 L350 374 L316 394 L258 378 L196 396 Z' - default: - return 'M166 370 L202 306 L256 336 L310 306 L346 370 L310 392 L256 380 L202 392 Z' - } -} - -export function buildMbtiSvgAvatarDataUrl(type: MbtiType): string { - const p = MBTI_AVATAR_PROFILES[type] - const palette = paletteByGroup(p.group) - const face = faceByMood(p.mood) - const shoulder = shouldersByMood(p.mood) - - const svg = ` - - - - - - - - - - - - - - - - - - - - - - - - - -` - - return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` -} diff --git a/soul-admin/src/pages/api-docs/ApiDocsPage.tsx b/soul-admin/src/pages/api-docs/ApiDocsPage.tsx index 2696a0fd..dc8d3f96 100644 --- a/soul-admin/src/pages/api-docs/ApiDocsPage.tsx +++ b/soul-admin/src/pages/api-docs/ApiDocsPage.tsx @@ -4,7 +4,7 @@ * 来源:new-soul/soul-admin */ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key } from 'lucide-react' +import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key, Upload, Settings, Database } from 'lucide-react' interface EndpointBlockProps { method: string @@ -501,6 +501,268 @@ export function ApiDocsPage() { + {/* 8. Admin Chapters CRUD */} + + + + + 8. 管理端章节 CRUD (/api/admin/chapters) + + + +
        +

        + 管理端使用 /api/admin/chapters 进行章节增删改查,需 AdminAuth Bearer Token。 + 与 /api/db/book 的 action 模式不同,此处为标准 RESTful 风格。 +

        +
        + + 正文 HTML

        ", + "partId": "part-1", + "chapterId": "ch-1", + "isFree": true, + "price": 0, + "bookId": 1 +}`} + response={`{ + "success": true, + "data": { "id": "section-xxx", "title": "新章节标题" } +}`} + /> + 更新后正文

        ", + "isFree": false, + "price": 1.0 +}`} + response={`{ + "success": true, + "data": { "id": "section-1", "title": "更新后标题" } +}`} + /> + +
        +
        + + {/* 9. Upload */} + + + + + 9. 文件上传 (Upload) + + + +
        +

        + 通用上传接口,管理端与小程序均可调用。上传后得到 URL,可嵌入章节正文 HTML。若已配 OSS 则优先上传到 OSS,OSS 失败或未配置时回退本地磁盘。 +

        +
        + + +
        +
        + + {/* 10. Settings & Config */} + + + + + 10. 系统设置与配置 + + + + + + + + + + {/* 11. CKB Content Push */} + + + + + 11. 存客宝内容推送 + + + +
        +

        + 将章节内容推送到存客宝内容库,或提供拉取接口供存客宝主动获取。 +

        +
        + + +
        +
        +

        管理端主要使用 /api/admin/*、/api/db/*;富文本素材上传另用公共接口 /api/upload(与后台编辑器一致)。小程序使用 /api/miniprogram/*。完整实现见 soul-api 源码。

        diff --git a/soul-admin/src/pages/content/ChapterTree.tsx b/soul-admin/src/pages/content/ChapterTree.tsx index 8ecf220b..a079cf64 100644 --- a/soul-admin/src/pages/content/ChapterTree.tsx +++ b/soul-admin/src/pages/content/ChapterTree.tsx @@ -2,7 +2,7 @@ * 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序 * 整行可拖拽;节和章可跨篇 */ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -81,6 +81,8 @@ interface ChapterTreeProps { onToggleSectionSelect?: (sectionId: string) => void /** 查看某节的付款记录 */ onShowSectionOrders?: (s: SectionItem) => void + /** 查看某节的点击用户统计 */ + onShowClickStats?: (s: SectionItem) => void /** 置顶章节ID列表 */ pinnedSectionIds?: string[] } @@ -101,10 +103,33 @@ export function ChapterTree({ selectedSectionIds = [], onToggleSectionSelect, onShowSectionOrders, + onShowClickStats, pinnedSectionIds = [], }: ChapterTreeProps) { const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null) const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null) + const [expandedChapters, setExpandedChapters] = useState>(new Set()) + const hasAutoExpanded = useRef(false) + + useEffect(() => { + if (!hasAutoExpanded.current && parts.length > 0) { + const ids = new Set() + parts.forEach(p => p.chapters.forEach(ch => ids.add(ch.id))) + if (ids.size > 0) { + setExpandedChapters(ids) + hasAutoExpanded.current = true + } + } + }, [parts]) + + const toggleChapter = (chapterId: string) => { + setExpandedChapters(prev => { + const next = new Set(prev) + if (next.has(chapterId)) next.delete(chapterId) + else next.add(chapterId) + return next + }) + } // 章节ID/范围展示由数据本身决定,不再依赖“节序号”重新编号 @@ -297,6 +322,23 @@ export function ChapterTree({ const bodyPartOrdinal = (partIndex: number) => parts.slice(0, partIndex).filter((p) => !isPrefacePart(p)).length + const clickStatsBadge = (section: SectionItem) => { + const count = section.clickCount ?? 0 + if (onShowClickStats) { + return ( + + ) + } + return 点击 {count} + } + const sectionTitleLine = (section: SectionItem) => { return ( <> @@ -371,7 +413,7 @@ export function ChapterTree({ ) : ( ¥{sec.price} )} - 点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} + {clickStatsBadge(sec)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && (
        {isExpanded && part.chapters.length > 0 && (
        - {part.chapters.map((chapter) => ( + {part.chapters.map((chapter) => { + const chapterExpanded = expandedChapters.has(chapter.id) + return (
        -

        {chapter.title}

        + +

        toggleChapter(chapter.id)}>{chapter.title} ({chapter.sections.length} 节)

        e.stopPropagation()}> {onEditChapter && (
        -
        + {chapterExpanded &&
        {chapter.sections.map((section) => { const secDragOver = isDragOver('section', section.id) return ( @@ -530,7 +582,7 @@ export function ChapterTree({ {pinnedSectionIds.includes(section.id) && }
        e.stopPropagation()}> - 点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)} + {clickStatsBadge(section)} · 付款 {(section.payCount ?? 0)} 热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名 {onShowSectionOrders && (
        ) })} -
        +
        }
        - ))} + ) + })}
        )}
        @@ -601,7 +654,7 @@ export function ChapterTree({ {pinnedSectionIds.includes(sec.id) && }
        - 点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} + {clickStatsBadge(sec)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && (
        - 点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} + {clickStatsBadge(sec)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && ( +

        { e.stopPropagation(); toggleChapter(chapter.id) }}>{chapter.title} ({chapter.sections.length} 节)

        e.stopPropagation()}> {onEditChapter && ( @@ -900,7 +961,7 @@ export function ChapterTree({ )}
        -
        + {expandedChapters.has(chapter.id) &&
        {chapter.sections.map((section) => { const secDragOver = isDragOver('section', section.id) return ( @@ -955,7 +1016,7 @@ export function ChapterTree({ ) : ( ¥{section.price} )} - 点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)} + {clickStatsBadge(section)} · 付款 {(section.payCount ?? 0)} 热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名 {onShowSectionOrders && (
        ) })} -
        +
        }
        ) })} diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index cfacd6eb..d50e36bc 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -// useNavigate removed (@列表已迁移) +import { useNavigate } from 'react-router-dom' import toast from '@/utils/toast' import { Card, @@ -50,8 +50,13 @@ import { Pencil, Users, Webhook, + ArrowUp, + ArrowDown, + ListOrdered, + Link2, } from 'lucide-react' import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client' +import { ckbWorkbenchHref } from '@/lib/ckbWorkbench' import { ChapterTree } from './ChapterTree' import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal' // getPersonDetail 不再需要(@列表已整合到超级个体) @@ -60,33 +65,46 @@ import { apiUrl } from '@/api/client' /** 与 soul-api mergeReadPreviewUI 默认键一致;小程序用 {percent} {price} 占位符替换 */ const READ_PREVIEW_UI_TEMPLATE = JSON.stringify( { - singlePageUnlockTitle: '解锁完整内容', + singlePageUnlockTitle: '解锁全文', singlePagePayButtonText: '支付 ¥{price} 解锁全文', singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。', payTapModalTitle: '解锁说明', payTapModalContent: '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - fullUnlockTitle: '加入365读书会', - fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明', - fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读', - fullPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', - notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文', - notLoginPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', - shareTipLine: '好友经你分享购买,你可获得约 90% 收益', + fullUnlockTitle: '', + fullUnlockDesc: '', + fullLockedProgressText: '', + fullPaywallTip: '转发给需要的人,一起学习还能赚佣金', + notLoginUnlockDesc: '', + notLoginPaywallTip: '转发给需要的人,一起学习还能赚佣金', + shareTipLine: '转发给需要的人,一起学习还能赚佣金', momentsModalTitle: '分享到朋友圈', momentsModalContent: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。', momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——', - timelineTitleSuffix: '(预览{percent}%)', + timelineImageUrl: '', }, null, 2, ) +interface BookItem { + id: number + bookKey: string + title: string + subtitle: string + icon: string + iconEmoji: string + sortOrder: number + status: string + sectionCount?: number +} + interface SectionListItem { id: string mid?: number + bookId?: number title: string price: number isFree?: boolean @@ -225,6 +243,18 @@ function buildTree(sections: SectionListItem[], hotRankMap: Map, } export function ContentPage() { + const navigate = useNavigate() + const [booksList, setBooksList] = useState([]) + const [currentBookId, setCurrentBookId] = useState(1) + const [, setBooksLoading] = useState(false) + const [showBookModal, setShowBookModal] = useState(false) + const [editingBook, setEditingBook] = useState(null) + const [bookForm, setBookForm] = useState({ title: '', subtitle: '', iconEmoji: '', sortOrder: 0 }) + const [bookSaving, setBookSaving] = useState(false) + const [showBookSortModal, setShowBookSortModal] = useState(false) + const [bookSortDraft, setBookSortDraft] = useState([]) + const [bookSortSaving, setBookSortSaving] = useState(false) + const [sectionsList, setSectionsList] = useState([]) const [loading, setLoading] = useState(true) const [expandedParts, setExpandedParts] = useState([]) @@ -271,11 +301,15 @@ export function ContentPage() { const [batchMoveTargetPartId, setBatchMoveTargetPartId] = useState('') const [batchMoveTargetChapterId, setBatchMoveTargetChapterId] = useState('') const [isMoving, setIsMoving] = useState(false) + const [isPushingCkb, setIsPushingCkb] = useState(false) const [newPartTitle, setNewPartTitle] = useState('') const [isSavingPart, setIsSavingPart] = useState(false) const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null) const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false) + const [clickStatsModal, setClickStatsModal] = useState<{ sectionId: string; title: string; totalClicks: number; users: { userId: string; nickname: string; avatar: string; count: number; lastClick: string }[] } | null>(null) + const [clickStatsLoading, setClickStatsLoading] = useState(false) const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false) + const [showDisplayRules, setShowDisplayRules] = useState(false) const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 }) const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false) const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false) @@ -287,7 +321,7 @@ export function ContentPage() { const [previewPercent, setPreviewPercent] = useState(20) const [previewPercentLoading, setPreviewPercentLoading] = useState(false) const [previewPercentSaving, setPreviewPercentSaving] = useState(false) - const [fullbookThreshold, setFullbookThreshold] = useState(2) + const [fullbookThreshold, setFullbookThreshold] = useState(3) const [fullbookThresholdSaving, setFullbookThresholdSaving] = useState(false) const [readPreviewUiJson, setReadPreviewUiJson] = useState(READ_PREVIEW_UI_TEMPLATE) const [readPreviewUiLoading, setReadPreviewUiLoading] = useState(false) @@ -386,14 +420,30 @@ export function ContentPage() { const rankingTotalPages = Math.max(1, Math.ceil(rankedSectionsList.length / RANKING_PAGE_SIZE)) const rankingPageSections = rankedSectionsList.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE) - const loadList = async () => { + const loadBooks = async () => { + setBooksLoading(true) + try { + const data = await get<{ success?: boolean; books?: BookItem[] }>('/api/db/books', { cache: 'no-store' as RequestCache }) + if (data?.books) setBooksList(data.books) + } catch (e) { + console.error(e) + } finally { + setBooksLoading(false) + } + } + + const loadList = async (bookId?: number) => { setLoading(true) try { + const bid = bookId ?? currentBookId const data = await get<{ success?: boolean; sections?: SectionListItem[] }>( - '/api/db/book?action=list', + `/api/db/book?action=list&bookId=${bid}`, { cache: 'no-store' as RequestCache }, ) - setSectionsList(Array.isArray(data?.sections) ? data.sections : []) + const sections = Array.isArray(data?.sections) ? data.sections : [] + setSectionsList(sections) + const partIds = [...new Set(sections.map(s => s.partId).filter(Boolean))] as string[] + if (partIds.length > 0) setExpandedParts(partIds) } catch (e) { console.error(e) setSectionsList([]) @@ -402,6 +452,90 @@ export function ContentPage() { } } + const handleSwitchBook = (bookId: number) => { + setCurrentBookId(bookId) + setEditingSection(null) + loadList(bookId) + } + + const handleSaveBook = async () => { + if (!bookForm.title.trim()) { toast.error('书名不能为空'); return } + setBookSaving(true) + try { + if (editingBook) { + await put('/api/db/books', { id: editingBook.id, ...bookForm }) + toast.success('已更新') + } else { + await post('/api/db/books', bookForm) + toast.success('已创建') + } + setShowBookModal(false) + setEditingBook(null) + loadBooks() + } catch (e) { + toast.error('保存失败') + } finally { + setBookSaving(false) + } + } + + const handleDeleteBook = async (id: number) => { + if (id === 1) { toast.error('主书不可删除'); return } + try { + const res = await del<{ success?: boolean; error?: string }>(`/api/db/books?id=${id}`) + if (res?.success) { + toast.success('已删除') + loadBooks() + if (currentBookId === id) handleSwitchBook(1) + } else { + toast.error(res?.error || '删除失败') + } + } catch { toast.error('删除失败') } + } + + const openBookSortModal = () => { + setBookSortDraft([...booksList].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0) || a.id - b.id)) + setShowBookSortModal(true) + } + + const moveBookInDraft = (index: number, dir: -1 | 1) => { + const nextIndex = index + dir + if (nextIndex < 0 || nextIndex >= bookSortDraft.length) return + const next = [...bookSortDraft] + const [item] = next.splice(index, 1) + next.splice(nextIndex, 0, item) + setBookSortDraft(next.map((b, i) => ({ ...b, sortOrder: i + 1 }))) + } + + const handleSaveBookSort = async () => { + if (bookSortDraft.length === 0) return + setBookSortSaving(true) + try { + await put('/api/db/books?action=reorder', { + items: bookSortDraft.map((b, i) => ({ id: b.id, sortOrder: i + 1 })), + }) + toast.success('书籍排序已保存') + setShowBookSortModal(false) + await loadBooks() + } catch (e) { + console.error(e) + toast.error('保存排序失败') + } finally { + setBookSortSaving(false) + } + } + + const openBookModal = (book?: BookItem) => { + if (book) { + setEditingBook(book) + setBookForm({ title: book.title, subtitle: book.subtitle, iconEmoji: book.iconEmoji, sortOrder: book.sortOrder }) + } else { + setEditingBook(null) + setBookForm({ title: '', subtitle: '', iconEmoji: '', sortOrder: booksList.length + 1 }) + } + setShowBookModal(true) + } + const loadPartBadges = async () => { try { const res = await get<{ success?: boolean; data?: Record | Array<{ configKey?: string; configValue?: unknown }> }>( @@ -450,6 +584,7 @@ export function ContentPage() { } useEffect(() => { + loadBooks() loadList() loadRanking() loadPartBadges() @@ -902,7 +1037,7 @@ export function ContentPage() { const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'fullbook_show_threshold', value: fullbookThreshold, - description: '购买多少小节后显示「解锁全书」按钮(默认2)', + description: '购买多少小节后显示「加入读书会」按钮(默认3,与第二次付款露出规则一致可调)', }) if (res && (res as { success?: boolean }).success !== false) toast.success('阈值已保存') else toast.error('保存失败: ' + ((res as { error?: string }).error || '')) @@ -986,6 +1121,28 @@ export function ContentPage() { } } + const handleShowClickStats = async (section: { id: string; title: string }) => { + setClickStatsModal({ sectionId: section.id, title: section.title, totalClicks: 0, users: [] }) + setClickStatsLoading(true) + try { + const data = await get<{ success?: boolean; totalClicks?: number; users?: { userId: string; nickname: string; avatar: string; count: number; lastClick: string }[] }>( + `/api/admin/chapters/${encodeURIComponent(section.id)}/click-stats`, + ) + if (data?.success) { + setClickStatsModal({ + sectionId: section.id, + title: section.title, + totalClicks: data.totalClicks ?? 0, + users: data.users ?? [], + }) + } + } catch (e) { + console.error(e) + } finally { + setClickStatsLoading(false) + } + } + const handleReadSection = async (section: Section & { filePath?: string }) => { setIsLoadingContent(true) try { @@ -1135,6 +1292,7 @@ export function ContentPage() { '/api/db/book', { id: newSection.id, + bookId: currentBookId, title: newSection.title, price: newSection.isFree ? 0 : newSection.price, content: newSection.content || '', @@ -1393,6 +1551,7 @@ export function ContentPage() { const sectionId = `part-placeholder-${Date.now()}` const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { id: sectionId, + bookId: currentBookId, title: '占位节(可编辑)', price: 0, content: '', @@ -1418,6 +1577,30 @@ export function ContentPage() { } } + const handlePushToCkb = async () => { + if (selectedSectionIds.length === 0) { + toast.error('请先勾选要推送的章节') + return + } + setIsPushingCkb(true) + try { + const res = await post<{ success?: boolean; error?: string; message?: string; results?: Array<{ id: string; title: string; status: string }> }>( + '/api/admin/ckb/push-content', + { chapterIds: selectedSectionIds }, + ) + if (res?.success) { + toast.success(res.message || `已推送 ${selectedSectionIds.length} 条内容到存客宝`) + setSelectedSectionIds([]) + } else { + toast.error(res?.error || '推送失败') + } + } catch (err) { + toast.error('推送失败: ' + (err instanceof Error ? err.message : String(err))) + } finally { + setIsPushingCkb(false) + } + } + const handleBatchMoveToTarget = async () => { if (selectedSectionIds.length === 0) { toast.error('请先勾选要移动的章节') @@ -1562,24 +1745,124 @@ export function ContentPage() { const currentPart = tree.find((p) => p.id === newSection.partId) const chaptersForPart = currentPart?.chapters ?? [] + const currentBook = booksList.find((b) => b.id === currentBookId) + return (
        + {/* 书籍选择栏 */} +
        + {booksList.map((b) => ( + + ))} + + + {currentBook && ( + + )} +
        +
        -

        内容管理

        +

        {currentBook?.title || '内容管理'}

        共 {tree.length} 篇 · {totalSections} 节内容

        -
        +
        + +
        + {showDisplayRules && ( + + +
        +

        内容显示规则

        + +
        +
        +
        + + setPreviewPercent(Math.max(1, Math.min(100, Number(e.target.value) || 20)))} disabled={previewPercentLoading} /> + % +
        + + 默认显示文章前 {previewPercent}% +
        +
        +
        + + setFullbookThreshold(Math.max(1, Number(e.target.value) || 3))} /> + 小节 +
        + + ≥ {fullbookThreshold} 小节后显示「加入读书会 ¥365」 +
        +
        + 阅读页文案 JSON(点击展开) +
        +