diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md new file mode 100644 index 00000000..d91bef0a --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md @@ -0,0 +1,38 @@ +# 内容排版层级改造 (2026-04-07) + +## 问题 +28套私域商业模式章节在小程序中显示为纯文本流,所有内容同样字体大小,无层级感。h2/h3 被扁平化为普通 text,列表项带孤立 bullet 点(•),无缩进。 + +## 解决方案 + +### contentParser.js 新增段落类型 +- `heading` (level 2-6):从 `
` 提取 +- `listItem` (ordered/unordered):从 `` 提取,区分 ` ` 和 `
` 上下文 +- 保留 table / image / text / mention / linkTag + +### 解析流程(顺序关键) +1. 提取 `
` → 占位 +2. 提取 `
`~`
` → 占位 +3. 提取 `
` → 占位 +4. 处理 `/
/
- ` → 带标记的占位 +5. 清理其余块级标签 +6. 按 `\n+` 分段,逐段识别占位符类型 + +### WXML 渲染 +- heading:`seg-heading seg-hN` 类名,text user-select +- quote:`seg-quote`,左边框 + 背景 +- listItem:`seg-list-item`,flex 布局(marker + text) +- image:独立 paragraph +- 普通段落:原有逻辑 + +### WXSS 关键样式 +- `.seg-h2`: 40rpx, 底部分隔线 +- `.seg-h3`: 36rpx, 青色 (#00CED1) +- `.seg-quote`: 左 6rpx 青色边框 + 微背景 +- `.seg-list-item`: flex, marker 44rpx 宽,文本 30rpx + +### 上传脚本注意事项 +- `markdown` 库 + `tables` 扩展生成的 HTML 天然保留语义标签 +- `clean_html()` 要保留 h/blockquote/ul/ol/li,只清 `
`、空标签、`- ` 内 `
` +- 上传后必须通过 admin API PUT `/api/db/book` 触发 `cache.InvalidateChapterContent()` diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md b/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md new file mode 100644 index 00000000..a8c8b1b0 --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md @@ -0,0 +1,15 @@ +# 阅读页付费墙三态与营销句闭环(2026-04-11) + +## 问题 / 背景 + +- 登录前/后、朋友圈单页付费墙样式与「购买本章」框不一致;未登录长文案过多;底部营销句与已解锁分享区兜底不一致。 + +## 处理要点 + +- `read.wxml`:`paywall-marketing-box` 统一 90% 主标题;`readUi.shareTipLine` 作唯一底部营销句;已解锁区 `share-tip-text` 仅绑 `readUi.shareTipLine`,去掉旧兜底。 +- `read.js`:`readBeforeLoginHint` 默认置空(长说明改走配置或不再展示)。 +- 需求文档 `内容管理20260411.plan.md` 补充闭环与验收说明。 + +## 可复用规则 + +- 阅读页展示文案优先 `read_preview_ui`(章节接口合并);兜底在 `READ_UI_DEFAULTS` 与 `chapter_preview.go` 保持同步;管理端 ContentPage 模板同键。 diff --git a/.cursor/agent/小程序开发工程师/evolution/索引.md b/.cursor/agent/小程序开发工程师/evolution/索引.md index dba0947d..2637db19 100644 --- a/.cursor/agent/小程序开发工程师/evolution/索引.md +++ b/.cursor/agent/小程序开发工程师/evolution/索引.md @@ -14,3 +14,4 @@ | 2026-03-16 | 编辑资料页分享名片:转发/朋友圈特殊处理,Canvas 绘制封面,标题「昵称+为您分享名片」 | [2026-03-16.md](./2026-03-16.md) | | 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) | | 2026-03-30 | 阅读页 `navigateToMiniProgram` 时 path 追加 `phone`(已登录且已绑定) | [2026-03-30.md](./2026-03-30.md) | +| 2026-04-11 | 阅读付费墙三态框体统一、`shareTipLine` 与文末分享区一致、需求文档闭环 | [2026-04-11-阅读付费墙三态与营销句闭环.md](./2026-04-11-阅读付费墙三态与营销句闭环.md) | diff --git a/.cursor/agent/开发助理/项目索引/产品.md b/.cursor/agent/开发助理/项目索引/产品.md index fa432409..8d946781 100644 --- a/.cursor/agent/开发助理/项目索引/产品.md +++ b/.cursor/agent/开发助理/项目索引/产品.md @@ -6,7 +6,7 @@ ## 项目总结 -Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/需求汇总.md`,项目推进表在 `开发文档/10、项目管理/项目落地推进表.md`,临时需求/分析在 `临时需求池/`。 +Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/2026-03-20-需求.md`(主需求清单),闭环结论见 `开发文档/10、项目管理/闭环与文档同步-2026-04-07.md`,临时需求/分析在 `临时需求池/`。 --- @@ -30,9 +30,10 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。 | 2026-03-24 | 会议:需求与进度及三端闭环评审;规则/风控缺口清单化;VIP 支付前资料规则待拍板 | 已完成 | | 2026-03-31 | 会议:超级个体列表与 @ 列表融合方案(先数据互链再单页);一期验收口径待拍板 | 待续 | | 2026-04-02 | 会议:工作进度与需求同步;一期融合验收口径澄清、各端索引与需求 delta 跟进 | 进行中 | +| 2026-04-07 | 开发文档与主需求交叉同步;闭环分析落盘(主路径成立、规则/风控与纪要问题区待闭环) | 已完成 | > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置 --- -**最后更新**:2026-04-02 +**最后更新**:2026-04-07 diff --git a/.cursor/agent/开发助理/项目索引/助理橙子.md b/.cursor/agent/开发助理/项目索引/助理橙子.md index 58c60c32..5a2bb3af 100644 --- a/.cursor/agent/开发助理/项目索引/助理橙子.md +++ b/.cursor/agent/开发助理/项目索引/助理橙子.md @@ -25,9 +25,10 @@ | 2026-03-17 | 会议收尾:新版管理端迁移到稳定版实施方案确认;纪要、各角色经验入库、项目索引更新 | 已完成 | | 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿;纪要、经验入库、开发文档同步 | 已完成 | | 2026-04-02 | 会议收尾:工作进度与需求同步会;纪要、各角色 evolution、项目索引与会议索引更新 | 已完成 | +| 2026-04-07 | 同步开发文档与主需求;新增《闭环与文档同步-2026-04-07》并更新索引、sync-log | 已完成 | > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置 --- -**最后更新**:2026-04-02(会议收尾:工作进度与需求同步会) +**最后更新**:2026-04-07(文档同步与闭环分析) diff --git a/.cursor/agent/开发助理/项目索引/团队.md b/.cursor/agent/开发助理/项目索引/团队.md index 9334802d..e6373b73 100644 --- a/.cursor/agent/开发助理/项目索引/团队.md +++ b/.cursor/agent/开发助理/项目索引/团队.md @@ -33,9 +33,10 @@ Soul 创业派对全项目架构与约定:路由隔离(miniprogram/admin/db | 2026-03-24 | 会议:需求与进度及三端闭环评审;主路径闭环成立、规则闭环待加固;失败响应与 err.response 契约 | 已完成 | | 2026-03-31 | 会议:超级个体与 @ 列表融合;先数据与入口融合、路由边界不变 | 已完成 | | 2026-04-02 | 会议:工作进度与需求同步;融合落地顺序与 deploy 验证门禁、问题与作答区待闭环 | 已完成 | +| 2026-04-07 | 文档同步:需求与开发文档索引对齐;闭环结论:主路径成立、规则/风控待加固项见《闭环与文档同步-2026-04-07》 | 已完成 | > **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD --- -**最后更新**:2026-04-02 +**最后更新**:2026-04-07 diff --git a/.cursor/skills/assistant-doc-sync/sync-log.md b/.cursor/skills/assistant-doc-sync/sync-log.md index af467e11..0198657d 100644 --- a/.cursor/skills/assistant-doc-sync/sync-log.md +++ b/.cursor/skills/assistant-doc-sync/sync-log.md @@ -6,6 +6,10 @@ --- +## 2026-04-07 + +- 文档同步:开发文档索引、主需求 2026-03-20、需求索引、10 项目管理 README 与《闭环与文档同步-2026-04-07》交叉引用;项目索引助理橙子/团队追加进度 + ## 2026-04-02 - 会议收尾:工作进度与需求同步会;纪要 `.cursor/meeting/2026-04-02_工作进度与需求同步会.md`;各角色 evolution、项目索引(产品/后端/管理端/小程序/测试/团队)、meeting/README 已更新 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 74742bf4..66ba25ba 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -10,11 +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' -// 线上正式域名(真机/体验版/正式版强制使用此地址) +// 线上正式 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:8080' -// 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') @@ -60,7 +60,7 @@ App({ // 购买记录 purchasedSections: [], hasFullBook: false, - // VIP 会员(365天,包含增值版免费;与 hasFullBook=9.9 买断不同) + // VIP 会员(365天,包含增值版免费;与 hasFullBook=365读书会权益不同) isVip: false, vipExpireDate: '', @@ -568,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) @@ -1021,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) { @@ -1033,7 +1036,7 @@ App({ this.logout() } showError(msg) - rejectWithBody(msg, data) + rejectWithBody(msg) return } resolve(data) @@ -1042,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) @@ -1088,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') { @@ -1406,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/components/login-modal/login-modal.wxml b/miniprogram/components/login-modal/login-modal.wxml index f821e40e..e4022dd0 100644 --- a/miniprogram/components/login-modal/login-modal.wxml +++ b/miniprogram/components/login-modal/login-modal.wxml @@ -6,9 +6,9 @@
登录 卡若创业派对 {{desc}} -- @@ -121,56 +147,56 @@ class="preview-body" style="{{previewMaxHeightPx > 0 ? ('max-height:' + previewMaxHeightPx + 'px; overflow:hidden;') : ''}}" > -{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}} +{{readUi.shareTipLine}} - +- {{seg.text}} {{seg.mentionDisplay}} #{{seg.label}} - -- + -+ {{item[0].text}} + {{item[0].text}} + {{hdr}} {{cell}} + {{item[0].number}}. • {{item[0].text}} + + {{seg.text}} {{seg.mentionDisplay}} #{{seg.label}} - - - - -预览 {{effectivePreviewPercent}}% -- -+ + + @@ -213,65 +239,63 @@ class="preview-body" style="{{previewMaxHeightPx > 0 ? ('max-height:' + previewMaxHeightPx + 'px; overflow:hidden;') : ''}}" > -+ - {{readUi.singlePageUnlockTitle || '解锁完整内容'}} -{{readUi.fullUnlockDesc || '可先上滑阅读预览;需要全文时,点下方「支付¥1」查看说明'}} -- - {{readUi.singlePagePayButtonText || '支付解锁'}} ++ ++ +解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 ++ + {{readUi.singlePagePayButtonText}} 审核中,暂不支持购买 -- {{readUi.singlePageExpandedHint || '预览页不能直接付款,务必先点底栏「前往小程序」。'}} -- {{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}} -{{readUi.fullPaywallTip || '转发给需要的人,一起学习还能赚佣金'}} -- {{readUi.fullUnlockTitle || '加入365读书会'}} -{{readBeforeLoginHint}} -{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}} ++ + +解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 +- + 购买本章 -¥{{section && section.price != null ? section.price : sectionPrice}} +¥{{section && section.price != null ? section.price : sectionPrice}} - 手机号登录后购买 {{readUi.notLoginPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}} - +- {{seg.text}} {{seg.mentionDisplay}} #{{seg.label}} - -- + + {{item[0].text}} + {{item[0].text}} + {{hdr}} {{cell}} + {{item[0].number}}. • {{item[0].text}} + + {{seg.text}} {{seg.mentionDisplay}} #{{seg.label}} - - - - -预览 {{effectivePreviewPercent}}% -- -+ + + @@ -348,7 +372,7 @@+ - {{readUi.singlePageUnlockTitle || '解锁完整内容'}} -{{readUi.fullUnlockDesc || '可先上滑阅读预览;需要全文时,点下方「支付¥1」查看说明'}} -- - {{readUi.singlePagePayButtonText || '支付解锁'}} ++ ++ +解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 ++ + {{readUi.singlePagePayButtonText}} 审核中,暂不支持购买 -- {{readUi.singlePageExpandedHint || '预览页不能直接付款,务必先点底栏「前往小程序」。'}} -- {{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}} -{{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}} -- {{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 || '分享给好友,好友购买你可获得约 90% 收益'}} 长按海报可直接分享到微信 +长按海报可直接分享给好友 @@ -423,4 +447,63 @@+ + + + + + ++ ++ + +分享到朋友圈 ++ + + +好友购买,你获得 +{{shareRate || 90}}% +收益 ++ + ++ +1 +点击下方按钮复制文案 ++ +2 +点击右上角「···」→「分享到朋友圈」 ++ +3 +粘贴文案发布即可 ++ +{{momentsCopied ? '已复制,去发朋友圈' : '复制发圈文案'}} ++ + + ++ +请点击下方「前往小程序」查看全文 + ++ 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 d63ab325..dff1cbc5 100644 --- a/miniprogram/pages/referral/referral.js +++ b/miniprogram/pages/referral/referral.js @@ -67,33 +67,49 @@ Page({ posterNicknameInitial: '', /** 推广海报推荐人头像:优先微信头像,否则按 MBTI 用后台映射 */ posterDisplayAvatar: '', - posterCaseCount: 62 + posterCaseCount: 62, + dataLoading: true, + bindingFilter: 'active', + _lastLoadTime: 0, + auditMode: false }, onLoad(options) { this._focusTarget = (options && options.focus) || "" - this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + this.setData({ statusBarHeight: app.globalData.statusBarHeight, auditMode: app.globalData.auditMode || false }) this.initData() // 启用分享到朋友圈(需同时有 onShareAppMessage 和 onShareTimeline;menus 在 Android 支持,iOS 为 Beta) wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] }) }, onShow() { - // 从设置页返回时同步微信号绑定状态,便于提现按钮立即更新 const hasWechatId = !!(app.globalData.userInfo?.wechat || app.globalData.userInfo?.wechatId || wx.getStorageSync('user_wechat')) this.setData({ hasWechatId }) - this.initData() + const now = Date.now() + if (now - (this.data._lastLoadTime || 0) > 5 * 60 * 1000) { + this.initData() + } + }, + + onPullDownRefresh() { + this.setData({ _lastLoadTime: 0 }) + this.initData().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh()) + }, + + switchBindingFilter(e) { + const filter = e.currentTarget.dataset.filter || 'active' + let list = [] + if (filter === 'active') list = this.data.activeBindings + else if (filter === 'converted') list = this.data.convertedBindings + else if (filter === 'expired') list = this.data.expiredBindings + this.setData({ bindingFilter: filter, currentBindings: list }) }, - // 初始化数据 async initData() { const { isLoggedIn, userInfo } = app.globalData if (isLoggedIn && userInfo) { - // 显示加载提示 - wx.showLoading({ - title: '加载中...', - mask: true // 防止触摸穿透 - }) + this.setData({ dataLoading: true }) + wx.showLoading({ title: '加载中...', mask: true }) // 生成邀请码 const referralCode = userInfo.referralCode || 'SOUL' + (userInfo.id || Date.now().toString(36)).toUpperCase().slice(-6) @@ -164,7 +180,7 @@ Page({ bindingCount, visitCount: realData?.visitCount || 0, paidCount, - unboughtCount: expiringCount, // "即将过期"显示的是 expiringCount + unboughtCount, expiredCount, // 收益数据 - 格式化为两位小数 @@ -210,12 +226,11 @@ Page({ }) - // 隐藏加载提示 wx.hideLoading() + this.setData({ dataLoading: false, _lastLoadTime: Date.now() }) this._applyFocusTarget() } else { - // 未登录时也隐藏loading - this.setData({ isLoading: false }) + this.setData({ isLoading: false, dataLoading: false }) } }, @@ -930,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/referral/referral.wxml b/miniprogram/pages/referral/referral.wxml index a33b68bb..9b8812b9 100644 --- a/miniprogram/pages/referral/referral.wxml +++ b/miniprogram/pages/referral/referral.wxml @@ -24,8 +24,8 @@ - -{{pay365AnchorLabel}} +› ++ + diff --git a/miniprogram/pages/settings/settings.js b/miniprogram/pages/settings/settings.js index b60ba765..90bbb571 100644 --- a/miniprogram/pages/settings/settings.js +++ b/miniprogram/pages/settings/settings.js @@ -24,7 +24,8 @@ Page({ showBindModal: false, bindType: '', // phone | wechat | alipay bindValue: '', - showPrivacyModal: false + showPrivacyModal: false, + auditMode: false }, onLoad() { @@ -40,7 +41,8 @@ Page({ isLoggedIn: app.globalData.isLoggedIn, userInfo: app.globalData.userInfo, isDevMode: envVersion === 'develop', - version: displayVersion + version: displayVersion, + auditMode: app.globalData.auditMode || false }) this.loadBindingInfo() }, diff --git a/miniprogram/pages/settings/settings.wxml b/miniprogram/pages/settings/settings.wxml index bc101db3..87948e6a 100644 --- a/miniprogram/pages/settings/settings.wxml +++ b/miniprogram/pages/settings/settings.wxml @@ -21,7 +21,7 @@ @@ -43,9 +43,9 @@ - {{availableEarningsNum <= 0 ? '暂无可提现' : !hasWechatId ? '请先绑定微信号' : '申请提现 ¥' + availableEarnings}} + {{availableEarningsNum <= 0 ? '暂无可提现' : !hasWechatId ? '请先绑定联系方式' : '申请提现 ¥' + availableEarnings}} -为便于提现到账,请先到「设置」中绑定微信号 +为便于提现到账,请先到「设置」中绑定联系方式 查看提现记录 - + - - @@ -42,15 +42,15 @@ @@ -33,7 +33,7 @@ - 一键获取 + 快捷获取 同意 + + - - -微信号 +联系方式 + + @@ -97,7 +97,7 @@ 自动提现 -收益自动打款到微信零钱 +收益自动打款到账户 - - 提现方式 -微信零钱 +账户转账 提现账户 @@ -108,9 +108,9 @@- 提示:绑定微信号才能使用提现功能 + ++ @@ -122,7 +122,7 @@提示:绑定联系方式才能使用提现功能 - @@ -131,7 +131,7 @@绑定{{bindType === 'phone' ? '手机号' : bindType === 'wechat' ? '微信号' : '支付宝'}} +绑定{{bindType === 'phone' ? '手机号' : bindType === 'wechat' ? '联系方式' : '支付宝'}} - {{bindType === 'phone' ? '绑定手机号后可用于找伙伴匹配' : bindType === 'wechat' ? '绑定微信号后可用于找伙伴匹配和好友添加' : '绑定支付宝后可用于提现收益'}} + {{bindType === 'phone' ? '绑定手机号后可用于找伙伴匹配' : bindType === 'wechat' ? '绑定联系方式后可用于找伙伴匹配和好友添加' : '绑定支付宝后可用于提现收益'}} 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 @@ + + 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 717a2898..9299a909 100644 --- a/miniprogram/utils/contentParser.js +++ b/miniprogram/utils/contentParser.js @@ -129,6 +129,8 @@ function parseBlockToSegments(block, config) { const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/) const appIdMatch = tag.match(/data-app-id="([^"]*)"/) const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/) + const passPhoneMatch = tag.match(/data-pass-phone="([^"]*)"/) + const phoneParamMatch = tag.match(/data-phone-param-name="([^"]*)"/) const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, '')) const url = urlMatch ? urlMatch[1] : '' const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url' @@ -136,7 +138,9 @@ function parseBlockToSegments(block, config) { const tagId = tagIdMatch ? tagIdMatch[1] : '' const appId = appIdMatch ? appIdMatch[1] : '' const mpKey = mpKeyMatch ? mpKeyMatch[1] : '' - segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey }) + const passPhone = !!(passPhoneMatch && (passPhoneMatch[1] === '1' || /^true$/i.test(passPhoneMatch[1]))) + const phoneParamName = (phoneParamMatch && phoneParamMatch[1]) ? phoneParamMatch[1] : 'phone' + segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey, passPhone, phoneParamName }) } else if (/^(insertLinkTag 旧版产生,url 可能为空) @@ -170,52 +174,189 @@ function parseBlockToSegments(block, config) { return segs } +/** + * 解析+ ++ ++ 链接客资 ++ + + + + +绑定人物:{{personName}} +客资按用户去重;行为轨迹与推送 Webhook 同源,近 1 小时同行为已合并展示 ++ + + +客资 +行为轨迹 ++ + + ++ +暂无留资记录 ++ ++ +{{item.name || item.nickname || '访客'}} +{{item.createdAtText}} +来源:{{item.sourceLabel || item.source || '-'}} +手机:{{item.phone}} +微信:{{item.wechatId}} +推送:{{item.pushStatus}} +加载中... +上拉加载更多 +没有更多了 ++ ++ +暂无行为轨迹 ++ ++ +{{item.userNickname || item.userShort}} +{{item.timeAgo || item.createdAtText}} +{{item.actionLabel}}{{item.chapterTitle ? ' · ' + item.chapterTitle : ''}}{{item.target ? ' · ' + item.target : ''}} +{{item.moduleLabel}} +加载中... +上拉加载更多 +HTML 为结构化数据 { headers: string[], rows: string[][] } + */ +function parseTableHtml(tableHtml) { + const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim()) + const headers = [] + const rows = [] + + const headMatch = tableHtml.match(/]*>([\s\S]*?)<\/thead>/i) + if (headMatch) { + const thRe = /
]*>([\s\S]*?)<\/th>/gi + let m + while ((m = thRe.exec(headMatch[1])) !== null) { + headers.push(stripTags(m[1])) + } + } + + const bodyMatch = tableHtml.match(/ ]*>([\s\S]*?)<\/tbody>/i) + const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml + const trRe = /]*>([\s\S]*?)<\/tr>/gi + let trM + let isFirst = !headMatch + while ((trM = trRe.exec(bodyContent)) !== null) { + if (headMatch && trM[0] === (headMatch[0].match(/ ]*>[\s\S]*?<\/tr>/i) || [])[0]) continue + const cells = [] + const cellRe = / ]*>([\s\S]*?)<\/t[dh]>/gi + let cM + while ((cM = cellRe.exec(trM[1])) !== null) { + cells.push(stripTags(cM[1])) + } + if (!cells.length) continue + if (isFirst && !headers.length) { + headers.push(...cells) + isFirst = false + } else { + rows.push(cells) + } + } + return { headers, rows } +} + /** * 从 HTML 中解析出 lines(纯文本行)和 segments(含富文本片段) + * segment 新增类型:heading / quote / listItem * @param {string} html - * @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签;assetBase 用于补全图片相对 URL + * @param {object} [config] - { persons: [], linkTags: [], assetBase?: string } */ function parseHtmlToSegments(html, config) { const lines = [] const segments = [] - // 1. 块级标签换行,保留内联标签供后续解析 - let text = html + // 0. 提取 块 + const tables = [] + let text = html.replace(/
]*>[\s\S]*?<\/table>/gi, (match) => { + const idx = tables.length + tables.push(parseTableHtml(match)) + return '\n__TABLE_' + idx + '__\n' + }) + + // 1. 提取
/
→ heading 占位 + const headings = [] + text = text.replace(/
]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) { + const idx = headings.length + headings.push({ level: parseInt(lvl, 10), raw: inner.trim() }) + return '\n__H_' + idx + '__\n' + }) + + // 2. 提取 → quote 占位 + const quotes = [] + text = text.replace(/]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) { + const idx = quotes.length + quotes.push(inner.trim()) + return '\n__Q_' + idx + '__\n' + }) + + // 3. 列表 → listItem 占位(保留 ordered 标记) + var olDepth = 0 + var olCounter = 0 + text = text.replace(/]*>/gi, function () { olDepth++; olCounter = 0; return '\n' }) + text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' }) + text = text.replace(/
]*>/gi, '\n') + text = text.replace(/<\/ul>/gi, '\n') + text = text.replace(/
- ]*>([\s\S]*?)<\/li>/gi, function (_, inner) { + var cleaned = inner.replace(/
]*>/gi, '').replace(/<\/p>/gi, '').trim() + if (!cleaned) return '\n' + if (olDepth > 0) { + olCounter++ + return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n' + } + return '\n__LI_U__ ' + cleaned + '\n' + }) + + // 4. 块级标签换行 text = text.replace(/<\/p>\s*
]*>/gi, '\n\n') text = text.replace(/
]*>/gi, '') text = text.replace(/<\/p>/gi, '\n') text = text.replace(/
]*>/gi, '') text = text.replace(/<\/div>/gi, '\n') text = text.replace(/
/gi, '\n') - text = text.replace(/<\/?h[1-6][^>]*>/gi, '\n') - text = text.replace(/<\/?blockquote[^>]*>/gi, '\n') - text = text.replace(/<\/?ul[^>]*>/gi, '\n') - text = text.replace(/<\/?ol[^>]*>/gi, '\n') - text = text.replace(/- ]*>/gi, '• ') - text = text.replace(/<\/li>/gi, '\n') + text = text.replace(/
/gi, '\n') - // 2. 逐段解析 - const blocks = text.split(/\n+/) - for (const block of blocks) { + // 5. 逐段解析 + var blocks = text.split(/\n+/) + for (var bi = 0; bi < blocks.length; bi++) { + var block = blocks[bi] if (!block.trim()) continue - let blockSegs = parseBlockToSegments(block, config) + // table + var tblM = block.trim().match(/^__TABLE_(\d+)__$/) + if (tblM) { + var tbl = tables[parseInt(tblM[1], 10)] + if (tbl) { + lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n')) + segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }]) + } + continue + } + + // heading + var hM = block.trim().match(/^__H_(\d+)__$/) + if (hM) { + var h = headings[parseInt(hM[1], 10)] + if (h) { + var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim()) + lines.push(hText) + segments.push([{ type: 'heading', level: h.level, text: hText }]) + } + continue + } + + // quote + var qM = block.trim().match(/^__Q_(\d+)__$/) + if (qM) { + var qHtml = quotes[parseInt(qM[1], 10)] + if (qHtml) { + var qText = decodeEntities( + qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim() + ).replace(/\n{2,}/g, '\n') + lines.push(qText) + segments.push([{ type: 'quote', text: qText }]) + } + continue + } + + // ordered list item + var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/) + if (liOM) { + var liNum = parseInt(liOM[1], 10) + var liContent = liOM[2].trim() + var liSegs = parseBlockToSegments(liContent, config) + var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim()) + lines.push(liNum + '. ' + liText) + segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }]) + continue + } + + // unordered list item + var liUM = block.match(/^__LI_U__\s*(.*)/) + if (liUM) { + var liContent2 = liUM[1].trim() + var liSegs2 = parseBlockToSegments(liContent2, config) + var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim()) + lines.push(liText2) + segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }]) + continue + } + + // 普通段落 + var blockSegs = parseBlockToSegments(block, config) if (!blockSegs.length) continue - // 纯图片行独立成段 if (blockSegs.length === 1 && blockSegs[0].type === 'image') { lines.push('') segments.push(blockSegs) continue } - // 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx) if (config && (config.persons?.length || config.linkTags?.length)) { - const expanded = [] - for (const seg of blockSegs) { + var expanded = [] + for (var si = 0; si < blockSegs.length; si++) { + var seg = blockSegs[si] if (seg.type === 'text' && seg.text) { - const sub = matchLineToSegments(seg.text, config) - expanded.push(...sub) + expanded.push.apply(expanded, matchLineToSegments(seg.text, config)) } else { expanded.push(seg) } @@ -223,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) } @@ -327,7 +467,9 @@ function matchLineToSegments(line, config) { pagePath: tag.pagePath || '', tagId: tag.tagId || '', appId: tag.appId || '', - mpKey: tag.mpKey || '' + mpKey: tag.mpKey || '', + passPhone: !!tag.passPhone, + phoneParamName: tag.phoneParamName || 'phone' }) } else { segs.push({ type: 'text', text: full }) diff --git a/miniprogram/utils/mpPagePopups.js b/miniprogram/utils/mpPagePopups.js index 05c9be5a..6eaf5162 100644 --- a/miniprogram/utils/mpPagePopups.js +++ b/miniprogram/utils/mpPagePopups.js @@ -14,7 +14,8 @@ const READ_PATH = '/pages/read/read' /** 阅读页 mpUi 兜底(pagePopupItems / readPage 均无或为空时) */ const READ_PAGE_FALLBACK = { - beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。', + /** 已不在付费墙展示长说明;保留短句供兼容旧配置拉取 */ + beforeLoginHint: '', singlePageTitle: '解锁完整内容', singlePagePaywallHint: '当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。', diff --git a/miniprogram/utils/partIcons.js b/miniprogram/utils/partIcons.js index 24f59c5f..3444eb18 100644 --- a/miniprogram/utils/partIcons.js +++ b/miniprogram/utils/partIcons.js @@ -2,7 +2,7 @@ * 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致 * 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时) */ -const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖'] +const PART_ICONS = ['🧭', '🌍', '⚡', '💎', '🏆', '🚀', '🎯', '🔑', '🌟', '🎪'] /** 正文篇在列表中的从 0 开始的序号 → emoji(与 ChapterTree bodyPartOrdinal 一致) */ function partEmojiForBodyIndex(bodyIndex) { diff --git a/miniprogram/utils/ruleEngine.js b/miniprogram/utils/ruleEngine.js index 2f221679..3ce4490c 100644 --- a/miniprogram/utils/ruleEngine.js +++ b/miniprogram/utils/ruleEngine.js @@ -412,6 +412,90 @@ function checkRule_Withdraw(rules) { } } +/** + * 浏览 N 章后弹出 365 读书会支付引导 + * 规则匹配:triggerConditions 包含 view_n_chapters_pay365 或 browse_n_chapters + * 阈值来自 actionConfig.chapterThreshold(默认 3) + * actionConfig.revealMode:modal 进入章节即延时弹窗;anchor 仅展示锚点条,用户点击后再弹窗/支付 + * actionConfig.anchorLabel:锚点条文案(可选) + */ +function findRuleByTriggerCondition(rules, conditionKey) { + return rules.find(r => { + if (r.completed) return false + const tc = r.triggerConditions + if (Array.isArray(tc)) return tc.includes(conditionKey) + if (typeof tc === 'string') { + try { + const parsed = JSON.parse(tc) + if (Array.isArray(parsed)) return parsed.includes(conditionKey) + } catch (_) {} + } + return false + }) +} + +function parseActionConfig(rule) { + if (!rule) return {} + const ac = rule.actionConfig + if (ac && typeof ac === 'object' && !Array.isArray(ac)) return ac + if (typeof ac === 'string' && ac.trim()) { + try { + const p = JSON.parse(ac) + if (p && typeof p === 'object') return p + } catch (_) {} + } + return {} +} + +function checkRule_ViewNChaptersPay365(rules) { + const rule = findRuleByTriggerCondition(rules, 'view_n_chapters_pay365') + || findRuleByTriggerCondition(rules, 'browse_n_chapters') + if (!rule) return null + + const user = getUserInfo() + if (!user.id) return null + + const app = getAppInstance() + if (!app) return null + if (app.globalData.hasFullBook || app.globalData.isVip) return null + + const ac = parseActionConfig(rule) + const threshold = (Number(ac.chapterThreshold) > 0) ? Number(ac.chapterThreshold) : 3 + const fullRead = + typeof app.getReadCount === 'function' ? app.getReadCount() : app.globalData.readCount || 0 + const browseDistinct = + typeof app.getBrowseDistinctChapterCount === 'function' ? app.getBrowseDistinctChapterCount() : 0 + const readCount = Math.max(Number(fullRead) || 0, Number(browseDistinct) || 0) + if (readCount < threshold) return null + + if (isInCooldown('view_n_pay365')) return null + setCooldown('view_n_pay365') + + const ctaKind = String(ac.ctaKind || 'pay365') + const amount = ctaKind === 'pay365' ? 365 : null + + const rm = ac.revealMode + let revealMode = rm === 'modal' || rm === 'anchor' ? rm : null + if (!revealMode) { + revealMode = 'modal' + } + const anchorLabel = trimStr(ac.anchorLabel) + + return { + ruleId: 'view_n_pay365', + serverRuleId: rule.id, + title: rule.title || '加入 365 读书会', + message: rule.description || `你已阅读 ${readCount} 个章节,加入 365 读书会可解锁全部内容,一年内不限次阅读。`, + confirmText: amount ? `¥${amount} 立即加入` : '去了解', + cancelText: '再看看', + action: ctaKind === 'pay365' ? 'pay365' : 'navigate', + target: null, + amount, + revealMode, + anchorLabel, + } +} + function checkRulesSync(scene, rules) { const user = getUserInfo() if (!user.id) return null @@ -431,6 +515,8 @@ function checkRulesSync(scene, rules) { return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules) case 'before_join_party': return checkRule_JoinParty(rules) + case 'chapter_read': + return checkRule_ViewNChaptersPay365(rules) default: return null } @@ -452,6 +538,12 @@ function executeRule(rule, pageInstance) { if (typeof pageInstance.showPhoneBinding === 'function') { pageInstance.showPhoneBinding() } + } else if (rule.action === 'pay365' && pageInstance) { + if (typeof pageInstance.handlePurchaseFullBook === 'function') { + pageInstance.handlePurchaseFullBook() + } else if (typeof pageInstance.processPayment === 'function') { + pageInstance.processPayment('fullbook', null, rule.amount || 365) + } } if (rule.serverRuleId) { markRuleCompleted(rule.serverRuleId) @@ -485,8 +577,34 @@ async function checkAndExecute(scene, pageInstance) { rule = checkRulesSync(scene, rules) } if (rule) { + if ( + scene === 'chapter_read' && + rule.ruleId === 'view_n_pay365' && + rule.revealMode === 'anchor' && + rule.action === 'pay365' && + pageInstance && + typeof pageInstance.onPay365MarketingAnchor === 'function' + ) { + setTimeout(() => pageInstance.onPay365MarketingAnchor(rule), 800) + return + } setTimeout(() => executeRule(rule, pageInstance), 800) } } -module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted } +/** 阅读页同步营销态:与 chapter_read 同一套条件,不自动弹窗 */ +async function getChapterReadMarketingRule() { + const rules = await loadRules() + const user = getUserInfo() + if (!user.id) return null + return checkRulesSync('chapter_read', rules) +} + +module.exports = { + checkRules: checkRulesSync, + executeRule, + checkAndExecute, + loadRules, + markRuleCompleted, + getChapterReadMarketingRule, +} diff --git a/miniprogram/utils/soulBridge.js b/miniprogram/utils/soulBridge.js index a780f6e1..845f3699 100644 --- a/miniprogram/utils/soulBridge.js +++ b/miniprogram/utils/soulBridge.js @@ -18,9 +18,9 @@ function getReferralCodeForPay(app) { return '' } -/** 章节 / 全书支付描述(与 read 页原逻辑一致) */ +/** 章节 / 365读书会(fullbook)支付描述(与 read 页一致) */ function buildSectionPayDescription(productType, sectionId, sectionTitle) { - if (productType === 'fullbook') return '《一场Soul的创业实验》全书' + if (productType === 'fullbook') return '加入读书会' if (productType === 'section') { const t = sectionTitle || sectionId || '' const short = t.length > 20 ? t.slice(0, 20) + '...' : t diff --git a/scripts/batch_upload_ip_book.py b/scripts/batch_upload_ip_book.py new file mode 100644 index 00000000..a6aa4293 --- /dev/null +++ b/scripts/batch_upload_ip_book.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量上传《卡若的IP财富旅程》PART1-5 到小程序。 + +part_id : part-new-17755513182 +part_title: 卡若的IP财富旅程 +各 PART 对应独立 chapter: + PART1 -> chapter-ip-1 | PART1 | 我少年时期的互联网启蒙 + PART2 -> chapter-ip-2 | PART2 | 从0-1的过程 + PART3 -> chapter-ip-3 | PART3 | 我的创业旅程 + PART4 -> chapter-ip-4 | PART4 | 私域流量到AI时代的商业进化 + PART5 -> chapter-ip-5 | PART5 | 未来的一些思考 + +ID 格式: ip. 例: ip1.1, ip2.10, ip3.5 +""" +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +# 匹配 ✅1.5-标题 或 ✅1.5- 标题 形式的文件名,提取 section_num 和 title +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + """返回 [(ip_id, title, path)] 已去重且按节号排序""" + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + # 去掉 .md 后缀再匹配(处理双后缀 .md.md) + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + # 把文件名里的冒号/括号等清理出来作为标题 + title = title_raw.strip(":: \t") + # 还原完整标题(包含冒号) + title = title_raw + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + + # 优先选有 .md 后缀的文件(去重) + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + # 若新文件有 .md 后缀,替换 + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + # 按节号排序 + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, ip_id), + ) + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_skip = 0 + total_err = 0 + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for ip_id, title, path in files: + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败 {path.name}: {e}") + total_err += 1 + continue + + body = for_miniprogram_body(strip_md_title_line(raw)) + if dry_run: + print(f" [dry] {ip_id} {title[:30]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter(cur, chapter_id, chapter_title, ip_id, title, body, price) + conn.commit() + print(f" ✓ [{action}] {ip_id} {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 跳过 {total_skip} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/batch_upload_ip_book_preserve_images.py b/scripts/batch_upload_ip_book_preserve_images.py new file mode 100644 index 00000000..6dddeabc --- /dev/null +++ b/scripts/batch_upload_ip_book_preserve_images.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +《卡若的IP财富旅程》PART1-5 重新上传:正文以本地 MD 为准,已入库的图片块按顺序保留(不重新上传)。 + +- 从 chapters.content 提取现有 (或裸
)块,顺序与 MD 中独立图片行一一对应。 +- 无图片的章节:与 batch_upload_ip_book.py 相同(纯文本清洗)。 +- 同步 sort_order(与 fix_ip_book_sort_and_images 一致)。 +""" +from __future__ import annotations + +import html +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + +LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body_plain(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def extract_img_blocks_from_db(old_content: str) -> list[str]: + """从已入库正文中按文档顺序提取图片块(供复用 URL)。""" + if not old_content or "
]*>\s*(
]+/?>)\s*", s, re.IGNORECASE | re.DOTALL): + blocks.append(m.group(0).strip()) + if blocks: + return blocks + for m in re.finditer(r"
]+/?>", s, re.IGNORECASE): + blocks.append(f"
{m.group(0).strip()}
") + return blocks + + +def body_html_preserve_images(md_path: Path, old_content: str) -> tuple[str, list[str]]: + """ + 按 fix_ip_book 的 HTML 段落规则生成正文,图片行使用旧库中的块。 + 返回 (body, warnings) + """ + warnings: list[str] = [] + old_imgs = extract_img_blocks_from_db(old_content) + raw = md_path.read_text(encoding="utf-8") + lines = strip_md_title_line(raw).splitlines() + + img_idx = 0 + md_img_lines = sum(1 for ln in lines if LINE_IMAGE_ONLY.match(ln)) + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + if img_idx < len(old_imgs): + chunks.append(old_imgs[img_idx]) + img_idx += 1 + else: + warnings.append(f"MD 图片行多于库中已存图片 ({md_path.name}),已跳过该行图片") + chunks.append("(图片:库中无对应已上传块,请检查)
") + continue + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"{html.escape(stripped)}
") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + body = "\n".join(html_parts).strip() + "\n" + + rest = len(old_imgs) - img_idx + if rest > 0: + warnings.append(f"{md_path.name}: 库中多 {rest} 张图片未在 MD 中引用,已从新正文移除(按 MD 为准)") + if md_img_lines and not old_imgs: + warnings.append(f"{md_path.name}: MD 含图片行但库中正文无,请先跑 fix_ip_book_sort_and_images 上传图片,或使用纯文本上传") + + return body, warnings + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + title = title_raw + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def md_has_image_only_lines(md_path: Path) -> bool: + raw = md_path.read_text(encoding="utf-8") + text = strip_md_title_line(raw) + return any(LINE_IMAGE_ONLY.match(ln) for ln in text.splitlines()) + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float, sort_order: int): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s, sort_order=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order, ip_id), + ) + return "updated" + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price, sort_order) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_err = 0 + all_warnings: list[str] = [] + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + try: + has_imgs = md_has_image_only_lines(path) + if has_imgs: + cur.execute("SELECT content FROM chapters WHERE id = %s", (ip_id,)) + row = cur.fetchone() + old_content = row[0] if row and row[0] else "" + body, warns = body_html_preserve_images(path, old_content) + for w in warns: + all_warnings.append(f"{ip_id}: {w}") + else: + raw = path.read_text(encoding="utf-8") + body = for_miniprogram_body_plain(strip_md_title_line(raw)) + except Exception as e: + print(f" ✗ 处理失败 {ip_id} ({path.name}): {e}") + total_err += 1 + continue + + if dry_run: + mode = "html+保留图" if has_imgs else "plain" + print(f" [dry] {ip_id} sort={sort_order} [{mode}] {title[:36]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter( + cur, chapter_id, chapter_title, ip_id, title, body, price, sort_order + ) + conn.commit() + mode = "html" if has_imgs else "plain" + print(f" ✓ [{action}] {ip_id} sort={sort_order} [{mode}] {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if all_warnings: + print("\n⚠ 提示:") + for w in all_warnings[:50]: + print(f" - {w}") + if len(all_warnings) > 50: + print(f" ... 另有 {len(all_warnings) - 50} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_ip_book_sort_and_images.py b/scripts/fix_ip_book_sort_and_images.py new file mode 100644 index 00000000..df3b641d --- /dev/null +++ b/scripts/fix_ip_book_sort_and_images.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +修复《卡若的IP财富旅程》两个问题: +1. 排序错误:设置 sort_order(ip1.1=1, ip1.2=2, ...)使章节顺序正确 +2. 图片缺失:上传 ../images/ 里的图片到 OSS,更新 content 为含
的 HTML + +用法: + python3 scripts/fix_ip_book_sort_and_images.py + python3 scripts/fix_ip_book_sort_and_images.py --dry-run # 仅预览,不写库 +""" +from __future__ import annotations + +import html +import importlib.util +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PART_ID = "part-new-17755513182" + +CHAPTERS = { + 1: "chapter-ip-1", + 2: "chapter-ip-2", + 3: "chapter-ip-3", + 4: "chapter-ip-4", + 5: "chapter-ip-5", +} + +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def resolve_image(md_path: Path, ref: str) -> Path | None: + if ref.startswith(("http://", "https://")): + return None + p = (md_path.parent / ref).expanduser().resolve() + if p.is_file() and p.suffix.lower() in IMAGE_EXT: + return p + return None + + +def guess_mime(path: Path) -> str: + return {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp"}.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + try: + import requests + except ImportError: + sys.exit("需要: pip install requests") + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def has_image_refs(md_path: Path) -> bool: + text = md_path.read_text(encoding="utf-8") + return bool(re.search(r"^\s*!\[", text, re.MULTILINE)) + + +def md_to_html(md_path: Path, upload_cache: dict[str, str], dry_run: bool) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + chunks.append(f'
') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"
(图片路径无效:{html.escape(ref)})
") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'') + continue + # 普通文字行 + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"
{html.escape(stripped)}
") + else: + chunks.append("") + + # 压缩多余空行 + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def plain_body(md_path: Path) -> str: + """无图片章节:纯文本清洗(与原 batch_upload 一致)""" + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines).replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + seen: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + clean = re.sub(r"\.md(\.md)*$", "", f.name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean) + if not m or int(m.group(1)) != part_num: + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + ip_id = f"ip{p_num}.{s_num}" + key = f"{p_num}.{s_num}" + if key in seen: + if str(f).lower().endswith(".md") and not str(seen[key][2]).lower().endswith(".md"): + seen[key] = (ip_id, title_raw, f) + else: + seen[key] = (ip_id, title_raw, f) + result = list(seen.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_sort_fixed = 0 + total_img_updated = 0 + total_plain_updated = 0 + + for part_num, chapter_id in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} ({chapter_id})") + print(f"{'='*60}") + files = collect_files(part_num) + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + has_imgs = has_image_refs(path) + img_tag = "🖼" if has_imgs else " " + print(f"\n {img_tag} {ip_id} sort={sort_order} {title[:35]} ({path.name})") + + if has_imgs: + body = md_to_html(path, upload_cache, dry_run) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_img_updated += 1 + total_sort_fixed += 1 + else: + body = plain_body(path) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_plain_updated += 1 + total_sort_fixed += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"排序修复: {total_sort_fixed} 条") + print(f"图片章节更新: {total_img_updated} 条") + print(f"纯文本章节更新: {total_plain_updated} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/optimize_28_models.py b/scripts/optimize_28_models.py new file mode 100644 index 00000000..1a01ce84 --- /dev/null +++ b/scripts/optimize_28_models.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +上传/更新《28套私域商业模式》全部28章到小程序 + +功能: +- Markdown → 语义化 HTML(保留 h2/h3/blockquote/ul/ol/li/table/img) +- 图片自动上传到 OSS,替换本地路径 +- 预处理:去 # 标题行、去 ---、扁平化嵌套列表 +- 后处理:去
、去- 内
、合并空行、修孤立 bullet +- 写入 DB 后需要通过 admin API 清 Redis 缓存(脚本不含此步,见下方) + +用法: + python3 scripts/optimize_28_models.py --dry-run # 预览(不上传/不写库) + python3 scripts/optimize_28_models.py # 执行上传+写库 + +清缓存(上传完后手动执行): + 通过 admin API PUT /api/db/book 触发 cache.InvalidateChapterContent() + 参考本对话上下文中的缓存失效脚本 + +小程序前端要求: + contentParser.js 需支持 heading/quote/listItem/table/image 段落类型 + read.wxml/read.wxss 需有对应渲染组件与样式 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PARTS = [ + ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), + ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), + ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), +] + +MODEL_NUM_RE = re.compile(r"模式(\d+)") +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def preprocess_markdown(md_text: str) -> str: + """预处理 Markdown 源文本,解决嵌套列表产生的孤立 bullet 问题""" + lines = md_text.splitlines() + + # 去掉第一行如果是 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + result = [] + for line in lines: + # 把 "---" 分隔线去掉(标题已经能分隔段落) + if re.match(r"^\s*---+\s*$", line): + continue + result.append(line) + + # 合并嵌套列表:把 " - xxx" 子项合并为同级 "- xxx" + flat = [] + for line in result: + # 检测 2 空格或 4 空格缩进的子列表项 + m = re.match(r"^(\s{2,4})[-*]\s+(.*)", line) + if m: + flat.append(f"- {m.group(2)}") + else: + flat.append(line) + + return "\n".join(flat).strip() + + +def md_to_html(md_text: str) -> str: + """Markdown → 干净的 HTML""" + cleaned = preprocess_markdown(md_text) + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = clean_html(html) + return html.strip() + + +def clean_html(html: str) -> str: + """清理 HTML:去
、去- 内
、合并空标签、修孤立 bullet""" + + # 去掉所有
标签 + html = re.sub(r"
", "", html, flags=re.IGNORECASE) + + # 去掉- 内部的 包裹(防止 contentParser 把 bullet 和文本拆开) + #
- \n
→content
\n- content
+ html = re.sub( + r"- \s*
", + r"([\s\S]*?)
\s*- \1
", + html, + flags=re.IGNORECASE, + ) + + # 去掉空的 + html = re.sub(r"- \s*
", "", html, flags=re.IGNORECASE) + + # 去掉空的 + html = re.sub(r"\s*
", "", html, flags=re.IGNORECASE) + + # 把内的 "- xxx" 列表项拆出来为
- 格式 + def fix_inline_list(m): + before = m.group(1).strip() + body = m.group(2) + items = re.findall(r"-\s*(.+)", body) + if not items: + return m.group(0) + parts = [f"
{before}
"] if before else [] + parts.append("") + for item in items: + parts.append(f"
") + return "\n".join(parts) + + html = re.sub( + r"- {item.strip()}
") + parts.append("(.*?)\n((?:\s*-\s+.+\n?)+)
", + fix_inline_list, + html, + flags=re.IGNORECASE, + ) + + # 压缩 block 标签之间的空白:\n\n→ \n+ html = re.sub( + r"((?:blockquote|ul|ol|table|h[1-6]|p|div)>)\s*\n\s*\n\s*(<(?:h[1-6]|blockquote|ul|ol|table|p|div)[^>]*>)", + r"\1\n\2", + html, + flags=re.IGNORECASE, + ) + + # 合并连续 3+ 换行为 1 个 + html = re.sub(r"\n{3,}", "\n", html) + + return html + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def verify_segments(html: str, chapter_id: str): + """验证 HTML 渲染后不会出现孤立 bullet 和过多空行""" + import re as _re + text = html + text = _re.sub(r" ]*>[\s\S]*?
", "[TABLE]", text, flags=_re.IGNORECASE) + text = _re.sub(r"\s*]*>", "\n\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?h[1-6][^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?blockquote[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?ul[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"?ol[^>]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"- ]*>", "• ", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "[IMG]", text, flags=_re.IGNORECASE) + text = _re.sub(r"<[^>]+>", "", text) + + # 模拟 contentParser 的 split(/\n+/) 行为 + blocks = [b.strip() for b in re.split(r"\n+", text) if b.strip()] + issues = [] + for i, block in enumerate(blocks): + if block == "•" or block == "·": + issues.append(f" 段{i}: 孤立 bullet '{block}'") + + if issues: + print(f" ⚠ {chapter_id} 检测到 {len(issues)} 个格式问题:") + for issue in issues[:5]: + print(issue) + else: + print(f" ✓ {chapter_id} 格式检查通过") + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + verify_segments(html_content, ip_id) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_28_models_html.py b/scripts/reupload_28_models_html.py new file mode 100644 index 00000000..1cd58522 --- /dev/null +++ b/scripts/reupload_28_models_html.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《28套私域商业模式》:Markdown → HTML(含表格/图片/粗体/标题) +1. 遍历 3 篇目录,读取 .md 文件 +2. 把 Markdown 转为 HTML(保留表格、图片、引用、列表、粗体等) +3. 上传本地图片到 OSS,替换为线上 URL +4. 更新 chapters 表的 content 字段 + +用法: + python3 scripts/reupload_28_models_html.py --dry-run # 预览 + python3 scripts/reupload_28_models_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PARTS = [ + ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), + ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), + ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), +] + +MODEL_NUM_RE = re.compile(r"模式(\d+)") +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = re.sub(r"\n{3,}", "\n\n", html) + return html.strip() + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + """返回 [(model_num, section_title, path)]""" + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + preview = html_content[:200].replace("\n", " ") + print(f" [dry] 预览: {preview}...") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_gps_html.py b/scripts/reupload_gps_html.py new file mode 100644 index 00000000..cea25892 --- /dev/null +++ b/scripts/reupload_gps_html.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《游戏明星GPS导航》:Markdown → 高质量 HTML(表格/列表/粗体/标题/引用/图片)。 +与 reupload_28_models_html.py 同样使用 python-markdown 做完整转换。 + +用法: + python3 scripts/reupload_gps_html.py --dry-run # 预览 + python3 scripts/reupload_gps_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《游戏明星GPS导航》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + +BOOK_STRUCTURE = [ + ("part-gps-0", "序、什么是游戏明星", "chapter-gps-0", "什么是游戏明星", [ + (1, "敬告", "序_什么是游戏明星/敬告.md"), + (2, "誓言", "序_什么是游戏明星/誓言.md"), + (3, "一、什么是游戏明星", "序_什么是游戏明星/一、什么是游戏明星.md"), + (4, "二、为什么要做游戏明星", "序_什么是游戏明星/二、为什么要做游戏明星.md"), + (5, "三、你好,未来的游戏明星", "序_什么是游戏明星/三、方向不对,努力全费...三、你好,未来的游戏明星.md"), + (6, "四、赚回十倍学费的一个小礼物", "序_什么是游戏明星/四、赚回十倍学费的一个小礼物.md"), + (7, "五、卡若与玩值电竞的故事", "序_什么是游戏明星/五、卡若与玩值电竞的故事.md"), + ]), + ("part-gps-1", "第一章、打开粉丝宝库的钥匙", "chapter-gps-1", "打开粉丝宝库的钥匙", [ + (1, "粉丝是如何判断你有没有吸引力的", "第1章_打开粉丝宝库的钥匙/粉丝是如何判断你有没有吸引力的.md"), + (2, "一、我是如何发现这些粉丝的秘籍的", "第1章_打开粉丝宝库的钥匙/一、我是如何发现这些粉丝的秘籍的(打开粉丝宝库的钥匙).md"), + (3, "二、目标粉丝聚集地(你的粉丝在哪里?)", "第1章_打开粉丝宝库的钥匙/二、目标粉丝聚集地(你的粉丝在哪里?).md"), + (4, "三、价值输出载体(找出你与粉丝交流的桥梁)", "第1章_打开粉丝宝库的钥匙/三、价值输出载体(找出你与粉丝交流的桥梁).md"), + (5, "四、不为人知的具体吸粉秘籍(总论)", "第1章_打开粉丝宝库的钥匙/四,不为人知的具体吸粉秘籍...(手把手带你吸粉).md"), + (6, "4.1、斗鱼直播间粉丝自动流入", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.1,斗鱼直播间粉丝自动流入【吸粉秘籍】.md"), + (7, "4.2、原创游戏视频大脑缺口弥补术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.2,原创游戏视频大脑缺口弥补术【吸粉秘籍】.md"), + (8, "4.3、批量渗透玩家微信群", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.3,批量渗透玩家微信群【吸粉秘籍】.md"), + (9, "4.4、朋友圈粉丝自我说服裂变", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.4,朋友圈粉丝自我说服裂变【吸粉秘籍】.md"), + (10, "4.5、淘宝老客户快速转移进入你的领地", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.5、淘宝老客户快速转移进入你的领地【吸粉秘籍】.md"), + (11, "4.6、人性换粉(威力巨大)", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.6,人性换粉(不管你在任何时间,任何场合永远有效,威力巨大).md"), + (12, "QQ空间红包引流术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/QQ空间红包引流术.md"), + (13, "微博问答降维吸粉", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/10,微博问答降维人吸粉.md"), + (14, "一个提醒", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/一个提醒.md"), + (15, "五、在这章你学到的东西,和将要得到的东西", "第1章_打开粉丝宝库的钥匙/五、在这章你学到的东西,和将要得到的东西.md"), + ]), + ("part-gps-2", "第二章、粉丝互动养熟秘籍", "chapter-gps-2", "粉丝互动养熟秘籍", [ + (1, "粉丝对你的信任程度,与你的收入成正比", "第2章_粉丝互动养熟秘籍/粉丝对你的信任程度,以你的收入成正比。.md"), + (2, '让粉丝疯狂追捧的\u201c聊天技巧\u201d在哪里?', '第2章_粉丝互动养熟秘籍/让粉丝疯狂追棒的\u201c聊天技巧\u201d在哪里?.md'), + (3, "一、粉丝信任升级逻辑", "第2章_粉丝互动养熟秘籍/一、粉丝信任升级逻辑.md"), + (4, "二、如何在互动中,快速制造粉丝对你的好印象", "第2章_粉丝互动养熟秘籍/二、如何在互动中,快速制造粉丝对你的好印象.md"), + (5, "三、神奇的信任升级导图", "第2章_粉丝互动养熟秘籍/三、神奇的信任升级导图.md"), + (6, "四、3种立即见效的日常互动模型", "第2章_粉丝互动养熟秘籍/四、3种立既见效的日常互动模型.md"), + (7, "五、3个神秘的互动秘诀", "第2章_粉丝互动养熟秘籍/五、3个神秘的互动秘诀.md"), + (8, "六、4个步骤过滤超级粉丝", "第2章_粉丝互动养熟秘籍/六、4个步骤过滤超级粉丝.md"), + (9, "七、日常规划", "第2章_粉丝互动养熟秘籍/七、日常规划.md"), + ]), + ("part-gps-3", "第三章、粉丝转化系统", "chapter-gps-3", "粉丝转化系统", [ + (1, "一、转化的四个前提", "第3章_粉丝转化系统/一、转化的四个前提.md"), + (2, "二、破解转化障碍", "第3章_粉丝转化系统/二、破解转化障碍.md"), + (3, "三、行之有效的转化秘籍", "第3章_粉丝转化系统/三、行之有效的转化秘籍.md"), + (4, "3.1、钟摆楼:一分钟获得变现的机会", "第3章_粉丝转化系统/3.1 钟摆楼,如何在一分钟之内获得变现的机会.md"), + (5, "3.2、社群价值裂变", "第3章_粉丝转化系统/3.2 激起兴趣的一些具体方法,微信群价值裂变.md"), + (6, "3.3、直播间转化实战", "第3章_粉丝转化系统/3.3 直播间转化实战.md"), + (7, "如何真正地掌握这套转化系统", "第3章_粉丝转化系统/一些想说的话,如何真正地掌握这套转化系统.md"), + ]), + ("part-gps-4", "第四章、超级粉丝", "chapter-gps-4", "超级粉丝", [ + (1, "一、粉丝未来价值的重要性", "第4章_超级粉丝/一、粉丝未来价值的重要性.md"), + (2, "二、多维度升级为超级粉丝的谋略", "第4章_超级粉丝/二、多维度升级为超级粉丝的谋略.md"), + (3, "三、立体放大未来价值", "第4章_超级粉丝/三、立体放大未来价值.md"), + (4, "四、从超级粉丝到合伙人", "第4章_超级粉丝/四、从超级粉丝到合伙人.md"), + ]), + ("part-gps-5", "第五章、提升影响力的几个细节", "chapter-gps-5", "提升影响力的几个细节", [ + (1, "一、当你觉得Hold不住自己野心时的处理方法", "第5章_提升影响力的几个细节/一、当你觉得Hold不住自己野心时的处理方法.md"), + (2, "二、保持你的个性,创造自己的小领地", "第5章_提升影响力的几个细节/二、保持你的个性,创造自己的小领地.md"), + (3, "三、如何让粉丝支持你", "第5章_提升影响力的几个细节/三、如何让粉丝和支持你.md"), + (4, "四、提升影响力前必须注意的细节", "第5章_提升影响力的几个细节/四、提升影响力前必须注意的细节.md"), + (5, "五、潜意识认同", "第5章_提升影响力的几个细节/五、潜意识认同.md"), + (6, "细节一、头像(视觉锤)", "第5章_提升影响力的几个细节/提升影响力的具体细节/一、头像.md"), + (7, "细节二、关联性昵称", "第5章_提升影响力的几个细节/提升影响力的具体细节/二、关联性昵称.md"), + (8, "细节三、个性签名", "第5章_提升影响力的几个细节/提升影响力的具体细节/三、个性签名.md"), + (9, "细节四、定位", "第5章_提升影响力的几个细节/提升影响力的具体细节/四、定位.md"), + (10, "案例:咨询卡若", "第5章_提升影响力的几个细节/案例/咨询卡若 (目前为了拒绝一些没什么问题的用户,你花半小时解答,他花30秒看完).md"), + ]), + ("part-gps-6", "第六章、建造属于自己的游戏明星领地", "chapter-gps-6", "建造属于自己的游戏明星领地", [ + (1, "一、什么是老铁", "第6章_建造属于自己的游戏明星领地/一、什么是老铁.md"), + (2, "二、如何设置领地规则", "第6章_建造属于自己的游戏明星领地/二、如何设置领地规则.md"), + (3, "三、玩值电竞——你的游戏明星大本营", "第6章_建造属于自己的游戏明星领地/三、玩值电竞——你的游戏明星大本营.md"), + (4, "四、领地的商业模式设计", "第6章_建造属于自己的游戏明星领地/四、领地的商业模式设计.md"), + (5, "如何坚持下去", "第6章_建造属于自己的游戏明星领地/如何坚持下去.md"), + ]), + ("part-gps-appendix-a", "附录A、玩值电竞入门指南", "chapter-gps-appendix-a", "玩值电竞入门指南", [ + (1, "玩值电竞入门指南", "附录_玩值电竞入门指南/玩值电竞入门指南.md"), + ]), + ("part-gps-appendix-b", "附录B、干货工具箱", "chapter-gps-appendix-b", "干货工具箱", [ + (1, "吸粉话术模板库", "附录_干货工具箱/吸粉话术模板库.md"), + (2, "游戏明星日常运营SOP", "附录_干货工具箱/游戏明星日常运营SOP.md"), + (3, "游戏明星自检清单", "附录_干货工具箱/游戏明星自检清单.md"), + ]), +] + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL(保持 markdown 格式)""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓") + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def clean_excessive_blank_lines(md_text: str) -> str: + """压缩连续 3+ 空行为 2 空行,确保 markdown 解析正常""" + return re.sub(r"\n{3,}", "\n\n", md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片/代码块""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + cleaned = clean_excessive_blank_lines(cleaned) + html_out = markdown.markdown( + cleaned, + extensions=["tables", "fenced_code", "nl2br"], + ) + html_out = re.sub(r"\n{3,}", "\n\n", html_out) + return html_out.strip() + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql markdown requests") + + dry_run = "--dry-run" in sys.argv + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + print(f"\n 📄 {section_id} | {section_title}") + + try: + raw = md_path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, md_path, upload_cache, dry_run) + html_content = md_to_html(processed) + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML={len(html_content)}字符, 纯文字={word_count}") + has_table = "
Path | None: + if ref.startswith(("http://", "https://")): + return None + p = (md_path.parent / ref).expanduser().resolve() + if p.is_file() and p.suffix.lower() in IMAGE_EXT: + return p + return None + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + import requests + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def md_to_html(md_path: Path, upload_cache: dict[str, str], dry_run: bool) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + chunks.append(f'
') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"
(图片路径无效:{html.escape(ref)})
") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'') + continue + stripped = line.strip() + if stripped.startswith("##"): + heading = stripped.lstrip("#").strip() + chunks.append(f"
{html.escape(heading)}
") + elif stripped: + chunks.append(f"{html.escape(stripped)}
") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def has_image_refs(md_path: Path) -> bool: + text = md_path.read_text(encoding="utf-8") + return bool(re.search(r"^\s*!\[", text, re.MULTILINE)) + + +def plain_body(md_path: Path) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def ensure_book(cur, conn) -> int: + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + print(f"书已存在: book_id={row[0]}") + return row[0] + + cur.execute("SELECT MAX(sort_order) FROM books") + max_sort = cur.fetchone()[0] or 0 + + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, max_sort + 1, "published"), + ) + conn.commit() + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + book_id = cur.fetchone()[0] + print(f"已创建书: book_id={book_id}, key={BOOK_KEY}, title={BOOK_TITLE}") + return book_id + + +def upsert_section(cur, conn, book_id: int, section_id: str, part_id: str, part_title: str, + chapter_id: str, chapter_title: str, section_title: str, + content: str, sort_order: int, price: float): + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + "UPDATE chapters SET book_id=%s, section_title=%s, content=%s, " + "part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, " + "price=%s, sort_order=%s, word_count=%s, status=%s, " + "edition_standard=%s, edition_premium=%s WHERE id=%s", + (book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, sort_order, len(content), "published", + True, False, section_id), + ) + conn.commit() + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, book_id, section_title, content, " + "part_id, part_title, chapter_id, chapter_title, " + "price, is_free, sort_order, word_count, status, " + "edition_standard, edition_premium) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + (section_id, book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, False, sort_order, len(content), "published", + True, False), + ) + conn.commit() + return "created" + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + if not dry_run: + book_id = ensure_book(cur, conn) + else: + book_id = 0 + print("[dry-run] 跳过创建书") + + upload_cache: dict[str, str] = {} + global_sort = 0 + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f" part_id={part_id} chapter_id={chapter_id}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + global_sort += 1 + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + has_imgs = has_image_refs(md_path) + img_tag = "🖼" if has_imgs else " " + + try: + if has_imgs: + content = md_to_html(md_path, upload_cache, dry_run) + else: + content = plain_body(md_path) + except Exception as e: + print(f" ✗ 读取失败 {rel_path}: {e}") + total_err += 1 + continue + + if dry_run: + print(f" {img_tag} [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + continue + + try: + action = upsert_section( + cur, conn, book_id, section_id, + part_id, part_title, chapter_id, chapter_title, + section_title, content, global_sort, price, + ) + print(f" ✓ [{action}] [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/upload_solo_company_book.py b/scripts/upload_solo_company_book.py new file mode 100644 index 00000000..dff67a1c --- /dev/null +++ b/scripts/upload_solo_company_book.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +上传《一人公司——你能掌控的自由人生》到小程序。 + +book_key : solo-company +共 10 章 + 1 术语手册,分 4 个篇。 + +用法: + python3 scripts/upload_solo_company_book.py --dry-run # 预览 + python3 scripts/upload_solo_company_book.py # 执行上传 +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《一人公司》") + +BOOK_KEY = "solo-company" +BOOK_TITLE = "一人公司" +BOOK_SUBTITLE = "你能掌控的自由人生" +BOOK_EMOJI = "🏢" +BOOK_SORT_ORDER = 10 + +# 篇章结构定义 +PARTS = { + "part-solo-1": { + "title": "启程·方向", + "chapters": { + "chapter-solo-1": { + "title": "找准方向,别再瞎忙", + "sections": [1, 2, 3], + } + }, + }, + "part-solo-2": { + "title": "实战·方法", + "chapters": { + "chapter-solo-2": { + "title": "用对方法,高效变现", + "sections": [4, 5, 6], + } + }, + }, + "part-solo-3": { + "title": "进阶·系统", + "chapters": { + "chapter-solo-3": { + "title": "搭建系统,持续增长", + "sections": [7, 8, 9, 10], + } + }, + }, + "part-solo-appendix": { + "title": "附录", + "chapters": { + "ch-solo-appendix": { + "title": "术语手册", + "sections": ["glossary"], + } + }, + }, +} + +# 章节文件映射 +SECTION_FILES = { + 1: "1、你越努力越穷,是因为你连方向都没找对.md", + 2: "2、9个人干出千万业绩:一人公司到底怎么运转的.md", + 3: "3、别再找项目了——你自己就是最值钱的产品.md", + 4: "4、我从不追求原创,但我每篇内容都赚到了钱.md", + 5: "5、30个账号每天自动来客户——你也能复制这套打法.md", + 6: "6、你认识谁,比你会什么值钱10倍.md", + 7: "7、搭一台你睡着了也在赚钱的机器.md", + 8: "8、你赚多少钱,取决于你把自己当成什么人.md", + 9: "9、一天重启你的人生:我用了10年验证的方法.md", + 10: "10、别等准备好再开始——这游戏根本没有终点.md", + "glossary": "术语手册:这本书里的黑话全在这.md", +} + +# 章节标题(去掉序号前缀) +SECTION_TITLES = { + 1: "你越努力越穷,是因为你连方向都没找对", + 2: "9个人干出千万业绩:一人公司到底怎么运转的", + 3: "别再找项目了——你自己就是最值钱的产品", + 4: "我从不追求原创,但我每篇内容都赚到了钱", + 5: "30个账号每天自动来客户——你也能复制这套打法", + 6: "你认识谁,比你会什么值钱10倍", + 7: "搭一台你睡着了也在赚钱的机器", + 8: "你赚多少钱,取决于你把自己当成什么人", + 9: "一天重启你的人生:我用了10年验证的方法", + 10: "别等准备好再开始——这游戏根本没有终点", + "glossary": "术语手册:这本书里的黑话全在这", +} + +DB_CONFIG = { + "host": "56b4c23f6853c.gz.cdb.myqcloud.com", + "port": 14413, + "user": "cdb_outerroot", + "password": "Zhiqun1984", + "database": "soul_miniprogram", +} + + +def md_to_html(md_text: str) -> str: + """将 Markdown 转为美观的 HTML(适配小程序 contentParser)""" + lines = md_text.splitlines() + # 去掉第一行 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + html_parts: list[str] = [] + i = 0 + + def inline_format(text: str) -> str: + """处理行内格式:粗体、斜体、行内代码、链接""" + # 粗体+斜体 + text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text) + # 粗体 + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + # 斜体 + text = re.sub(r"(?\1", text) + # 行内代码 + text = re.sub(r"`(.+?)`", r"\1", text) + # 链接 + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text) + return text + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # 空行跳过 + if not stripped: + i += 1 + continue + + # 图片 → 跳过(小程序不支持外链图片) + if re.match(r"^!\[.*\]\(.*\)$", stripped): + i += 1 + continue + + # 水平线 + if stripped == "---": + html_parts.append("
") + i += 1 + continue + + # h2 + if stripped.startswith("## "): + heading_text = inline_format(stripped[3:].strip()) + html_parts.append(f"{heading_text}
") + i += 1 + continue + + # h3 + if stripped.startswith("### "): + heading_text = inline_format(stripped[4:].strip()) + html_parts.append(f"{heading_text}
") + i += 1 + continue + + # 引用块 + if stripped.startswith("> "): + quote_lines = [] + while i < len(lines) and lines[i].strip().startswith("> "): + quote_lines.append(inline_format(lines[i].strip()[2:].strip())) + i += 1 + html_parts.append(f"{'") + continue + + # 待办列表 - [ ] / - [x] + if re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines) and re.match(r"^- \[[ x]\] ", lines[i].strip()): + s = lines[i].strip() + checked = s[3] == "x" + text = inline_format(s[6:].strip()) + check_mark = "✅" if checked else "☐" + items.append(f"
'.join(quote_lines)}- {check_mark} {text}
") + i += 1 + html_parts.append(f"{''.join(items)}
") + continue + + # 有序列表 + if re.match(r"^\d+\.\s+", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^\d+\.\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"- {text}
") + i += 1 + # 如果下一行是缩进的续行,合并进当前 li + while i < len(lines) and lines[i].strip() and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("- ") and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">") and lines[i].startswith(" "): + items[-1] = items[-1].replace("", f"
{inline_format(lines[i].strip())}") + i += 1 + html_parts.append(f"{''.join(items)}
") + continue + + # 无序列表(含多级缩进 *) + if re.match(r"^[-*]\s+", stripped) and not re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^[-*]\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"- {text}
") + i += 1 + # 缩进续行 + while i < len(lines) and lines[i].strip() and not re.match(r"^[-*]\s+", lines[i].strip()) and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">"): + if lines[i].startswith(" ") or lines[i].startswith("\t"): + sub = lines[i].strip() + sub_m = re.match(r"^[-*]\s+(.+)", sub) + if sub_m: + items[-1] = items[-1].replace("", f"
• {inline_format(sub_m.group(1))}") + else: + items[-1] = items[-1].replace("", f"
{inline_format(sub)}") + i += 1 + else: + break + html_parts.append(f"{''.join(items)}
") + continue + + # 普通段落 + para_lines = [inline_format(stripped)] + i += 1 + html_parts.append(f"{para_lines[0]}
") + continue + + return "\n".join(html_parts) + + +def find_section_location(section_key) -> tuple[str, str, str, str]: + """根据 section key 找到对应的 part/chapter 信息""" + for part_id, part_info in PARTS.items(): + for ch_id, ch_info in part_info["chapters"].items(): + if section_key in ch_info["sections"]: + return part_id, part_info["title"], ch_id, ch_info["title"] + return "part-solo-1", "启程·方向", "chapter-solo-1", "找准方向,别再瞎忙" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price_free = 0.0 + price_paid = 1.0 + + conn = pymysql.connect(**DB_CONFIG) + cur = conn.cursor() + + # ── Step 1: 确保书籍存在 ── + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + book_id = row[0] + print(f"📖 书籍已存在: id={book_id}, key={BOOK_KEY}") + if not dry_run: + cur.execute( + "UPDATE books SET title=%s, subtitle=%s, icon_emoji=%s, sort_order=%s, status='published' WHERE id=%s", + (BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER, book_id), + ) + conn.commit() + else: + if dry_run: + print(f"📖 [dry] 将创建书籍: {BOOK_KEY} - {BOOK_TITLE}") + book_id = 999 + else: + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) VALUES (%s, %s, %s, %s, %s, 'published')", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER), + ) + conn.commit() + book_id = cur.lastrowid + print(f"📖 已创建书籍: id={book_id}, key={BOOK_KEY}") + + # ── Step 2: 上传各章节 ── + total_ok = 0 + total_err = 0 + sort_order = 0 + + for section_key in list(range(1, 11)) + ["glossary"]: + filename = SECTION_FILES[section_key] + title = SECTION_TITLES[section_key] + section_id = f"solo-{section_key}" if isinstance(section_key, int) else "solo-glossary" + part_id, part_title, chapter_id, chapter_title = find_section_location(section_key) + + filepath = BOOK_DIR / filename + if not filepath.exists(): + print(f" ⚠ 文件不存在: {filepath}") + total_err += 1 + continue + + raw = filepath.read_text(encoding="utf-8") + html_content = md_to_html(raw) + word_count = len(re.sub(r"<[^>]+>", "", html_content)) + + is_free = section_key in [1, "glossary"] + price = price_free if is_free else price_paid + + if dry_run: + preview = html_content[:80].replace("\n", " ") + print(f" [dry] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字 | {'免费' if is_free else '付费'} | {preview}…") + total_ok += 1 + sort_order += 1 + continue + + try: + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + """UPDATE chapters SET + book_id=%s, section_title=%s, content=%s, word_count=%s, + part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, + price=%s, is_free=%s, sort_order=%s, status='published', + edition_standard=1, edition_premium=0 + WHERE id=%s""", + (book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, section_id), + ) + conn.commit() + print(f" ✓ [updated] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + else: + cur.execute( + """INSERT INTO chapters + (id, book_id, section_title, content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, status, edition_standard, edition_premium) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published', 1, 0)""", + (section_id, book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order), + ) + conn.commit() + print(f" ✓ [created] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + sort_order += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + else: + print("✅ 上传完毕!请在管理端或小程序验证。") + + +if __name__ == "__main__": + main() diff --git a/soul-admin/.deploy-manager.sync.json b/soul-admin/.deploy-manager.sync.json new file mode 100644 index 00000000..ebfc777f --- /dev/null +++ b/soul-admin/.deploy-manager.sync.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "updatedAt": "2026-04-07T08:22:54.262Z", + "projectId": "1775203188158", + "projectName": "Soul-管理端(正式)", + "kind": "static", + "deploy": { + "staticAppRoot": "E:\\Gongsi\\Mycontent\\soul-admin", + "staticBasePath": "/www/wwwroot/self/soul-admin", + "staticBuildCmd": "pnpm run build", + "staticDistDir": "dist", + "remoteBasePath": "", + "domain": "" + } +} diff --git a/soul-admin/src/components/RichEditor.tsx b/soul-admin/src/components/RichEditor.tsx index b5409a83..43d17dd6 100644 --- a/soul-admin/src/components/RichEditor.tsx +++ b/soul-admin/src/components/RichEditor.tsx @@ -40,6 +40,8 @@ export interface LinkTagItem { type: 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal' appId?: string pagePath?: string + passPhone?: boolean + phoneParamName?: string /** 管理端列表用:库内是否已存目标小程序 AppSecret(接口不下发明文) */ hasAppSecret?: boolean } @@ -196,6 +198,8 @@ const LinkTagExtension = Node.create({ pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' }, appId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-app-id') || '' }, mpKey: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-mp-key') || '' }, + passPhone: { default: false, parseHTML: (el: HTMLElement) => (el.getAttribute('data-pass-phone') || '') === '1' }, + phoneParamName: { default: 'phone', parseHTML: (el: HTMLElement) => el.getAttribute('data-phone-param-name') || 'phone' }, } }, @@ -208,6 +212,8 @@ const LinkTagExtension = Node.create({ pagePath: el.getAttribute('data-page-path')|| '', appId: el.getAttribute('data-app-id') || '', mpKey: el.getAttribute('data-mp-key') || '', + passPhone: (el.getAttribute('data-pass-phone') || '') === '1', + phoneParamName: el.getAttribute('data-phone-param-name') || 'phone', }) }] }, @@ -221,6 +227,8 @@ const LinkTagExtension = Node.create({ 'data-page-path': node.attrs.pagePath, 'data-app-id': node.attrs.appId || '', 'data-mp-key': node.attrs.mpKey || node.attrs.appId || '', + 'data-pass-phone': node.attrs.passPhone ? '1' : '0', + 'data-phone-param-name': node.attrs.phoneParamName || 'phone', class: 'link-tag-node', }), `#${node.attrs.label}`] }, @@ -562,6 +570,8 @@ const RichEditor = forwardRef(({ pagePath: tag.pagePath || '', appId: tag.appId || '', mpKey: tag.type === 'miniprogram' ? (tag.appId || '') : '', + passPhone: !!tag.passPhone, + phoneParamName: tag.phoneParamName || 'phone', }, }, { type: 'text', text: ' ' }, 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}
-@@ -600,7 +600,7 @@ export const CkbLeadsPanel = forwardRef去重用户数(按 userId)
-+
去重用户
+{leadsStats.uniqueUsers ?? 0}
: [] ).map((s) => ( -))} @@ -610,10 +610,7 @@ export const CkbLeadsPanel = forwardRef来源:{s.source}
+{s.source}
{s.cnt}
--+失败原因聚合
-基于当前页筛选结果,按失败原因聚合统计
-失败聚合
{!leadsLoading && leadsRows.length > 0 && (setLeadsSearchTerm(e.target.value)} className="pl-9 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500" @@ -673,9 +670,9 @@ export const CkbLeadsPanel = forwardRef title="按业务类型筛选" > - - - + + + - 已选 {leadSelectedIds.length} 条 · 可翻页继续勾选 · - 改搜索/筛选会清空选择 + 已选 {leadSelectedIds.length} 条 {leadSelectedIds.length > 0 && (
昵称 手机号 微信号 -对应 @人 -获客计划(Key) -推送状态 +@人 +计划Key +状态 时间 -重试 +操作 @@ -949,8 +945,8 @@ export const CkbLeadsPanel = forwardRef 暂无获客线索
{debouncedLeadsSearch.trim() || leadsSourceFilter - ? '当前搜索或来源筛选下没有匹配记录,可清空条件后重试。' - : '存客宝场景产生的手机号 / 微信留资会出现在此列表。请确认获客计划已开启,并有用户完成留资。'} + ? '当前筛选无匹配记录,可清空后重试' + : '获客计划产生的留资会出现在此列表'}
>({}) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - const [generating, setGenerating] = useState(false) + const [autoAssigning, setAutoAssigning] = useState(false) + /** 某类型图片加载失败或 src 无效时展示文字占位 */ + const [imgBroken, setImgBroken] = useState >({}) const load = useCallback(async () => { setLoading(true) @@ -32,6 +49,7 @@ export function MbtiAvatarsManager() { const res = await get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars') if (res?.avatars) setAvatars(res.avatars) else setAvatars({}) + setImgBroken({}) } catch { toast.error('加载 MBTI 头像配置失败') } finally { @@ -60,29 +78,26 @@ export function MbtiAvatarsManager() { } } - const generateAll = () => { - setGenerating(true) + const autoAssign16pAvatars = () => { + setAutoAssigning(true) try { - const next = { ...avatars } - MBTI_TYPES_ORDERED.forEach((t) => { - next[t] = buildMbtiSvgAvatarDataUrl(t) - }) + const pack = resolvePackGender() + const next: Record = { ...avatars } + for (const t of MBTI_TYPES_ORDERED) { + next[t] = staticPngPathForType(t, pack) + } setAvatars(next) - toast.success('16 型头像已生成(仅人物)') + setImgBroken({}) + toast.success( + `已自动分配 16 型 PNG 头像(${pack === 'male' ? '男版' : '女版'} /static/mbti-avatars-png-${pack}/;请点击保存映射)`, + ) + } catch { + toast.error('自动分配失败') } finally { - setGenerating(false) + setAutoAssigning(false) } } - const fill16pAvatars = (gender: 'male' | 'female') => { - const next = { ...avatars } - MBTI_TYPES_ORDERED.forEach((t) => { - next[t] = apiUrl(`/static/mbti-avatars-png-${gender}/${t}.png`) - }) - setAvatars(next) - toast.success(`已填充 16P ${gender === 'male' ? '男版' : '女版'}(记得点保存)`) - } - if (loading) { return ( @@ -95,47 +110,85 @@ export function MbtiAvatarsManager() { return (--fill16pAvatars('male')}> - 使用男版 - -fill16pAvatars('female')}> - 使用女版 - -- - 生成 SVG + + + {autoAssigning ? '分配中…' : '自动分配头像'} - - {saving ? '保存中…' : '保存'} + {saving ? '保存中…' : '保存映射'} { - try { - const res = await post<{ success?: boolean; assigned?: number; total?: number; message?: string }>('/api/admin/mbti-avatars/batch-assign', {}) - if (res?.success) toast.success(`已为 ${res.assigned}/${res.total} 位无头像用户分配默认头像`) - else toast.error(res?.message || '分配失败') - } catch { toast.error('分配失败') } - }}> + { + try { + const res = await post<{ success?: boolean; assigned?: number; total?: number; message?: string }>( + '/api/admin/mbti-avatars/batch-assign', + {}, + ) + if (res?.success) toast.success(`已为 ${res.assigned}/${res.total} 位无头像用户分配默认头像`) + else toast.error(res?.message || '分配失败') + } catch { + toast.error('分配失败') + } + }} + > 批量分配给无头像用户 -+ 刷新 选择风格后保存,无头像用户按 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 ( -+) : user ? ( -) })} 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- {url ?{ (e.target as HTMLImageElement).style.display = 'none' }} /> : 未配} + {showPlaceholder ? ( + + {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} +>({}) 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 ( <> - {/* 头部:身份 + 收益(紧凑) */} ---- {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 ? ( +- {user.referralCode && ( -setAvatarBroken(true)} + /> + ) : ( + user.nickname?.charAt(0) || '?' + )}
- 推荐码
- )} -{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)}
-未打款部分
----账户余额
-{ - setAdjustAmount('') - setAdjustRemark('') - setAdjustBalanceOpen(true) - }} - > - 调整 - ++-++累计佣金
+¥{(user.earnings ?? 0).toFixed(2)}
+++待提现
+¥{(user.pendingEarnings ?? 0).toFixed(2)}
+++++账户余额
+{ setAdjustAmount(''); setAdjustRemark(''); setAdjustBalanceOpen(true) }} + > + 调整 + +¥{(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)} /> + {/* ===== 用户信息:基础字段 + 超级个体 + 外部同步 ===== */} ++ +- {(user.region || user.industry || user.position || user.mbti) && ( -+
++ 编辑资料 + ++ {(user.region || user.industry || user.position || user.mbti) && ( ++ + setEditNickname(e.target.value)} /> +++ + setEditPhone(e.target.value)} /> +++ + setEditWechatId(e.target.value)} /> ++ {user.region && ( + ++ )}{user.region} + + )} + {user.industry && 行业 {user.industry}} + {user.position && 职位 {user.position}} + {user.mbti && MBTI {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 || '—'}
+--{vipForm.isVip && ( -- 超级个体 + ++ 超级个体 {user.isVip && {user.vipRole || 'VIP'} }setVipForm((f) => ({ ...f, isVip: c }))} /> -- - setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))} /> +++ + setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))} />-- -