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 0e08b6ea..66ba25ba 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -10,16 +10,22 @@ const mpPagePopups = require('./utils/mpPagePopups.js') const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' const DEFAULT_MCH_ID = '1318592501' const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' -// baseUrl 手动切换(注释方式): -// const API_BASE_URL = 'http://localhost:9090' -const API_BASE_URL = 'https://soulapi.quwanzhi.com' -// const API_BASE_URL = 'https://souldev.quwanzhi.com' +// 线上正式 API(真机/体验版/正式版)。永平仓库默认正式域;部署前核对见 .cursor/skills/security-server-ops/SKILL.md +const API_BASE_URL_PROD = 'https://soulapi.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') + || (wx.getSystemInfoSync && wx.getSystemInfoSync().platform === 'devtools') +const API_BASE_URL = _isDevTools ? API_BASE_URL_DEV : API_BASE_URL_PROD const CONFIG_CACHE_KEY = 'mpConfigCacheV1' /** 新用户未完成头像+昵称前,强制留在资料引导(与 avatar-nickname 页配合) */ const PROFILE_GUIDE_REQUIRED_KEY = 'profile_guide_required' // 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version(正式版),否则用本字段 -const APP_DISPLAY_VERSION = '1.7.2' +const APP_DISPLAY_VERSION = '1.7.3' App({ globalData: { @@ -54,7 +60,7 @@ App({ // 购买记录 purchasedSections: [], hasFullBook: false, - // VIP 会员(365天,包含增值版免费;与 hasFullBook=9.9 买断不同) + // VIP 会员(365天,包含增值版免费;与 hasFullBook=365读书会权益不同) isVip: false, vipExpireDate: '', @@ -114,8 +120,20 @@ App({ onLaunch(options) { - // baseUrl 固定取 API_BASE_URL(通过注释切换) this.globalData.baseUrl = API_BASE_URL + if (!_isDevTools && API_BASE_URL.indexOf('127.0.0.1') !== -1) { + console.error('[App] 检测到真机使用 localhost,强制切换为线上地址') + this.globalData.baseUrl = API_BASE_URL_PROD + } + // 体验版/正式版:与上传提审前扫码体验一致,必须走线上正式域名(不依赖其它开关) + try { + const ai = wx.getAccountInfoSync && wx.getAccountInfoSync() + const ev = ai && ai.miniProgram && ai.miniProgram.envVersion + if (ev === 'trial' || ev === 'release') { + this.globalData.baseUrl = API_BASE_URL_PROD + } + } catch (_e) {} + console.log('[App] 运行环境:', _isDevTools ? '开发工具' : '真机/体验版', '| baseUrl:', this.globalData.baseUrl) // 昵称等隐私组件需先授权:input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用 if (typeof wx.onNeedPrivacyAuthorization === 'function') { wx.onNeedPrivacyAuthorization((resolve) => { @@ -548,6 +566,13 @@ App({ const userId = this.globalData.userInfo.id const base = (this.globalData.baseUrl || '').replace(/\/$/, '') 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) @@ -1001,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) { @@ -1013,7 +1036,7 @@ App({ this.logout() } showError(msg) - rejectWithBody(msg, data) + rejectWithBody(msg) return } resolve(data) @@ -1022,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) @@ -1068,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') { @@ -1164,14 +1183,54 @@ App({ } }, + /** 是否为微信小程序支付可用的 openid */ + looksLikeWxOpenId(s) { + const x = String(s || '').trim() + if (x.length < 24 || x.length > 64) return false + if (x.indexOf('user_') === 0) return false + if (x.charAt(0) !== 'o') return false + return /^o[a-zA-Z0-9_-]+$/.test(x) + }, + + /** 从缓存 / userInfo 取合法微信 openid */ + resolvePayOpenId() { + const u = this.globalData.userInfo || {} + const pick = (v) => { + const s = String(v || '').trim() + return this.looksLikeWxOpenId(s) ? s : '' + } + return pick(this.globalData.openId) || pick(wx.getStorageSync('openId')) || pick(u.openId || u.open_id) + }, + + /** 支付前确保拿到真实微信 openid */ + async ensurePayOpenId() { + let oid = this.resolvePayOpenId() + if (oid) { + this.globalData.openId = oid + try { wx.setStorageSync('openId', oid) } catch (_) {} + return oid + } + try { await this.getOpenId() } catch (_) {} + oid = this.resolvePayOpenId() + if (oid) { + this.globalData.openId = oid + try { wx.setStorageSync('openId', oid) } catch (_) {} + return oid + } + return '' + }, + // 获取openId (支付必需) async getOpenId() { if (!this.ensureFullAppForAuth()) { return null } - // 先检查缓存 + // 缓存中如果存的是 userId 等非法值,先清掉 const cachedOpenId = wx.getStorageSync('openId') - if (cachedOpenId) { + if (cachedOpenId && !this.looksLikeWxOpenId(cachedOpenId)) { + try { wx.removeStorageSync('openId') } catch (_) {} + this.globalData.openId = null + } else if (cachedOpenId && this.looksLikeWxOpenId(cachedOpenId)) { this.globalData.openId = cachedOpenId return cachedOpenId } @@ -1346,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 || '解锁完整内容'}} -{{readBeforeLoginHint}} -{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}} ++ + +解锁完整内容,分享得到 +{{shareRate || 90}} +% 收益 +- + 购买本章 -¥{{section && section.price != null ? section.price : sectionPrice}} +¥{{section && section.price != null ? section.price : sectionPrice}} - 手机号登录后购买 {{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}} - +- {{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 || '转发给需要的人,一起学习还能赚佣金'}} -- {{readUi.fullUnlockTitle || '解锁完整内容'}} -{{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 || '分享给好友一起学习,还能赚取佣金'}} 长按海报可直接分享到微信 +长按海报可直接分享给好友 @@ -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 aa0c85ec..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() { @@ -34,13 +35,14 @@ Page({ const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim() const displayVersion = wxPkgVersion || - (app.globalData.appDisplayVersion || '1.7.2') + (app.globalData.appDisplayVersion || '1.7.3') this.setData({ statusBarHeight: app.globalData.statusBarHeight, isLoggedIn: app.globalData.isLoggedIn, userInfo: app.globalData.userInfo, isDevMode: envVersion === 'develop', - version: displayVersion + version: displayVersion, + auditMode: app.globalData.auditMode || false }) this.loadBindingInfo() }, @@ -50,7 +52,7 @@ Page({ const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim() const displayVersion = wxPkgVersion || - (app.globalData.appDisplayVersion || '1.7.2') + (app.globalData.appDisplayVersion || '1.7.3') this.setData({ version: displayVersion }) 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 a964b7db..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' } ], @@ -68,15 +68,13 @@ Page({ async handlePurchase() { trackClick('vip', 'btn_click', '开通VIP') let userId = app.globalData.userInfo?.id - let openId = app.globalData.openId || app.globalData.userInfo?.open_id - if (!userId || !openId) { + if (!userId) { wx.showLoading({ title: '登录中...', mask: true }) try { await app.login() userId = app.globalData.userInfo?.id - openId = app.globalData.openId || app.globalData.userInfo?.open_id wx.hideLoading() - if (!userId || !openId) { + if (!userId) { wx.showToast({ title: '登录失败,请重试', icon: 'none' }) return } @@ -86,6 +84,15 @@ Page({ return } } + const openId = await app.ensurePayOpenId() + if (!openId) { + wx.showModal({ + title: '无法拉起支付', + content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', + showCancel: false, + }) + return + } // VIP 购买成功后再跳转资料页:购买前不拦截 this.setData({ purchasing: true }) const amount = this.data.price diff --git a/miniprogram/pages/wallet/wallet.js b/miniprogram/pages/wallet/wallet.js index 98b01d8b..c1c90d45 100644 --- a/miniprogram/pages/wallet/wallet.js +++ b/miniprogram/pages/wallet/wallet.js @@ -83,10 +83,7 @@ Page({ const userId = app.globalData.userInfo.id const amount = this.data.selectedAmount - let openId = app.globalData.openId - if (!openId) { - openId = await app.getOpenId() - } + const openId = await app.ensurePayOpenId() if (!openId) { wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none', duration: 2500 }) return diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json index b2ddab5b..632fbfb6 100644 --- a/miniprogram/project.config.json +++ b/miniprogram/project.config.json @@ -4,7 +4,7 @@ "description": "卡若创业派对 - 来自派对房的真实商业故事", "appid": "wxb8bbb2b10dec74aa", "setting": { - "urlCheck": true, + "urlCheck": false, "es6": true, "enhance": true, "postcss": true, @@ -54,5 +54,7 @@ "packOptions": { "ignore": [], "include": [] - } + }, + "projectname": "miniprogram", + "libVersion": "3.13.2" } \ No newline at end of file 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(/- {subTab === 'booking' &&
/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/deploy_kr_btapi_verify.py b/scripts/deploy_kr_btapi_verify.py new file mode 100644 index 00000000..01188d87 --- /dev/null +++ b/scripts/deploy_kr_btapi_verify.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +kr 宝塔:正式环境发布 + 宝塔面板 API 重启 Go 项目 + 线上冒烟验证。 + +说明(与现状一致): +- soul-api 的**文件上传与解压**仍通过 SSH/SFTP(稳定);**进程重启**使用宝塔「Go 项目」插件 API(与 master.py --restart-method btapi 一致)。 +- soul-admin 静态资源仍通过 soul-admin/master.py(SSH),宝塔无统一「站点文件 API」封装。 +- 环境变量与 soul-api/master.py 相同:DEPLOY_HOST、DEPLOY_PASSWORD、BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME 等。 + +用法: + python3 scripts/deploy_kr_btapi_verify.py # 部署 API+管理端 + 验证 + python3 scripts/deploy_kr_btapi_verify.py --verify-only # 仅公网冒烟(不部署) + python3 scripts/deploy_kr_btapi_verify.py --skip-bt-ping # 跳过宝塔 API 探活 +""" + +from __future__ import print_function + +import argparse +import hashlib +import os +import subprocess +import sys +import time + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SOUL_API = os.path.join(ROOT, "soul-api") +SOUL_ADMIN = os.path.join(ROOT, "soul-admin") + + +def _load_api_cfg(): + import importlib.util + + path = os.path.join(SOUL_API, "master.py") + spec = importlib.util.spec_from_file_location("soul_api_master_mod", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.get_cfg() + + +def bt_panel_ping(cfg): + """调用宝塔 /system?action=GetDiskInfo 校验 API 密钥与面板可达性。""" + try: + import requests + + try: + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except Exception: + pass + except ImportError: + print("[失败] 需要 requests:pip install requests") + return False + + url = (cfg.get("bt_panel_url") or "").rstrip("/") + key = cfg.get("bt_api_key") or "" + if not url or not key: + print("[失败] 缺少 BT_PANEL_URL 或 BT_API_KEY") + return False + req_time = int(time.time()) + sk_md5 = hashlib.md5(key.encode()).hexdigest() + req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() + try: + r = requests.post( + url + "/system?action=GetDiskInfo", + data={"request_time": req_time, "request_token": req_token}, + timeout=20, + verify=False, + ) + if r.status_code != 200: + print("[宝塔API 探活] HTTP %s" % r.status_code) + return False + j = r.json() if "application/json" in (r.headers.get("content-type") or "") else {} + # 部分面板返回 data 为列表或非标准结构,仅当明确 status=False 视为失败 + if isinstance(j, dict) and j.get("status") is False: + print("[宝塔API 探活] %s" % (j.get("msg") or j)) + return False + print("[宝塔API 探活] 成功(GetDiskInfo)", flush=True) + return True + except Exception as e: + print("[宝塔API 探活] 异常: %s" % e) + return False + + +def run_cmd(cwd, args, env=None): + print("", flush=True) + print(">>> %s" % " ".join(args), flush=True) + e = os.environ.copy() + if env: + e.update(env) + r = subprocess.run(args, cwd=cwd, env=e) + return r.returncode == 0 + + +def verify_public(base_api=None, base_admin=None): + """公网冒烟:健康检查 + 只读业务接口 + 管理端首页。""" + try: + import requests + + try: + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except Exception: + pass + except ImportError: + print("[失败] 验证阶段需要 requests") + return False + + base_api = (base_api or os.environ.get("VERIFY_API_BASE", "https://soulapi.quwanzhi.com")).rstrip("/") + base_admin = (base_admin or os.environ.get("VERIFY_ADMIN_BASE", "https://souladmin.quwanzhi.com")).rstrip("/") + + checks = [ + ("GET %s/health" % base_api, "%s/health" % base_api, lambda r: r.status_code == 200 and '"status"' in r.text), + ("GET %s/api/book/parts" % base_api, "%s/api/book/parts" % base_api, lambda r: r.status_code == 200), + ("GET %s/api/config" % base_api, "%s/api/config" % base_api, lambda r: r.status_code == 200), + ("GET %s/" % base_admin, "%s/" % base_admin, lambda r: r.status_code == 200 and "root" in r.text), + ] + + print("") + print("=" * 60) + print(" 线上冒烟验证") + print("=" * 60) + all_ok = True + for name, url, pred in checks: + try: + r = requests.get(url, timeout=20, verify=True) + ok = pred(r) + print(" [%s] %s" % ("OK" if ok else "FAIL", name)) + if not ok: + print(" status=%s len=%s" % (r.status_code, len(r.text or ""))) + all_ok = False + except Exception as ex: + print(" [FAIL] %s — %s" % (name, ex)) + all_ok = False + return all_ok + + +def main(): + parser = argparse.ArgumentParser(description="kr 宝塔:BT API 重启 + 部署 + 验证") + parser.add_argument("--verify-only", action="store_true", help="仅执行公网冒烟,不部署") + parser.add_argument("--skip-bt-ping", action="store_true", help="跳过宝塔面板 API 探活") + parser.add_argument( + "--restart-method", + choices=("btapi", "auto"), + default="auto", + help="soul-api master.py 的 --restart-method(默认先宝塔 Go 插件 API,失败再 SSH)", + ) + args = parser.parse_args() + + cfg = _load_api_cfg() + + if not args.verify_only and not args.skip_bt_ping: + if not bt_panel_ping(cfg): + print("[失败] 宝塔 API 探活未通过,已中止(可用 --skip-bt-ping 跳过)") + return 1 + + if args.verify_only: + return 0 if verify_public() else 1 + + py = sys.executable + if not run_cmd(SOUL_API, [py, "master.py", "--restart-method", args.restart_method]): + print("[失败] soul-api 部署失败") + return 1 + + if not run_cmd(SOUL_ADMIN, [py, "master.py"]): + print("[失败] soul-admin 部署失败") + return 1 + + time.sleep(2) + if not verify_public(): + print("") + print("[失败] 线上冒烟存在未通过项(部署已执行,请排查 Nginx/证书/业务)") + return 1 + + print("") + print(" 全部完成:已部署且冒烟通过。") + return 0 + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/scripts/download_chapter_images_from_md.py b/scripts/download_chapter_images_from_md.py new file mode 100644 index 00000000..3fdfa943 --- /dev/null +++ b/scripts/download_chapter_images_from_md.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +从书稿 Markdown 中抓取远程图片( 与
),下载到本地 images/, +并把正文中的 URL 替换为相对路径 images/文件名。 + +用法: + python3 scripts/download_chapter_images_from_md.py /path/to/第140场.md + python3 scripts/download_chapter_images_from_md.py --images-dir /path/to/images a.md b.md + python3 scripts/download_chapter_images_from_md.py --dry-run a.md + +依赖:requests(与 content_download.py 一致) +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlparse + +try: + import requests +except ImportError: + print("请安装: pip install requests", file=sys.stderr) + sys.exit(1) + +MD_IMG = re.compile(r"!\[[^\]]*\]\((https?://[^)\s]+)\)") +HTML_IMG = re.compile(r'
]+src=["\'](https?://[^"\']+)["\']', re.I) + + +def collect_urls(text: str) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for m in MD_IMG.finditer(text): + u = m.group(1).strip() + if u not in seen: + seen.add(u) + out.append(u) + for m in HTML_IMG.finditer(text): + u = m.group(1).strip() + if u not in seen: + seen.add(u) + out.append(u) + return out + + +def safe_filename(url: str, index: int) -> str: + path = unquote(urlparse(url).path) + base = Path(path).name + if not base or base == "/" or ".." in base: + base = f"image_{index:02d}.bin" + base = re.sub(r"[^\w.\-一-龥]", "_", base) + return base + + +def download_one(url: str, dest: Path, session: requests.Session) -> None: + r = session.get(url, timeout=60, headers={"User-Agent": "Mozilla/5.0"}) + r.raise_for_status() + dest.write_bytes(r.content) + + +def process_file( + md_path: Path, + images_dir: Path, + dry_run: bool, + session: requests.Session, +) -> tuple[int, int]: + text = md_path.read_text(encoding="utf-8") + urls = collect_urls(text) + if not urls: + print(f"{md_path}: 无远程图片") + return 0, 0 + + used_names: dict[str, str] = {} # url -> local filename + basename_count: dict[str, int] = {} + ok = fail = 0 + + for i, url in enumerate(urls, 1): + name = safe_filename(url, i) + stem = Path(name).stem + ext = Path(name).suffix + if name in basename_count: + basename_count[name] += 1 + name = f"{stem}_{basename_count[name]}{ext}" + else: + basename_count[name] = 0 + + local_rel = f"images/{name}" + used_names[url] = name + + dest = images_dir / name + print(f" GET {url} -> {local_rel}") + if dry_run: + ok += 1 + continue + images_dir.mkdir(parents=True, exist_ok=True) + try: + download_one(url, dest, session) + ok += 1 + except Exception as e: + print(f" FAIL {url}: {e}", file=sys.stderr) + fail += 1 + + if dry_run or fail: + return ok, fail + + new_text = text + for url, name in used_names.items(): + local_rel = f"images/{name}" + new_text = new_text.replace(f"]({url})", f"]({local_rel})") + new_text = new_text.replace(f'src="{url}"', f'src="{local_rel}"') + new_text = new_text.replace(f"src='{url}'", f"src='{local_rel}'") + + md_path.write_text(new_text, encoding="utf-8") + print(f" 已写回: {md_path}") + return ok, fail + + +def main() -> None: + ap = argparse.ArgumentParser(description="从 md 下载远程插图并改为相对路径") + ap.add_argument("md", nargs="+", type=Path, help="Markdown 文件路径") + ap.add_argument( + "--images-dir", + type=Path, + default=None, + help="图片目录,默认与第一个 md 同级的 images/", + ) + ap.add_argument("--dry-run", action="store_true", help="只打印不下载、不改文件") + args = ap.parse_args() + + first = args.md[0].resolve() + images_dir = ( + args.images_dir.resolve() + if args.images_dir + else first.parent / "images" + ) + + session = requests.Session() + total_ok = total_fail = 0 + for p in args.md: + p = p.resolve() + if not p.is_file(): + print(f"跳过(非文件): {p}", file=sys.stderr) + continue + print(f"=== {p.name}") + ok, fail = process_file(p, images_dir, args.dry_run, session) + total_ok += ok + total_fail += fail + + print(f"完成: 成功 {total_ok}, 失败 {total_fail}") + if total_fail: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_mbti_avatars_16p_colored.py b/scripts/download_mbti_avatars_16p_colored.py new file mode 100644 index 00000000..e266ed81 --- /dev/null +++ b/scripts/download_mbti_avatars_16p_colored.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +下载 16Personalities 官网静态彩色小人 SVG(国内最流行的 MBTI 视觉)。 + +与 DiceBear 不同,这是站内「紫人/绿人」梗图同源画风。 + +⚠️ 版权:素材版权归 NERIS Analytics Limited / 16personalities.com。 + 商用、再分发、放入小程序包体前请自行阅读其条款并取得授权;本脚本仅作本地备份与联调用途,风险自负。 + +用法: + python3 scripts/download_mbti_avatars_16p_colored.py + python3 scripts/download_mbti_avatars_16p_colored.py --gender male +""" + +from __future__ import annotations + +import argparse +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_QUERY = "?v=3" + +# 与官网路径一致:{type}-{role}-{gender}.svg +SIXTEEN: list[tuple[str, str]] = [ + ("INTJ", "architect"), + ("INTP", "logician"), + ("ENTJ", "commander"), + ("ENTP", "debater"), + ("INFJ", "advocate"), + ("INFP", "mediator"), + ("ENFJ", "protagonist"), + ("ENFP", "campaigner"), + ("ISTJ", "logistician"), + ("ISFJ", "defender"), + ("ESTJ", "executive"), + ("ESFJ", "consul"), + ("ISTP", "virtuoso"), + ("ISFP", "adventurer"), + ("ESTP", "entrepreneur"), + ("ESFP", "entertainer"), +] + +BASE = "https://www.16personalities.com/static/images/personality-types/avatars" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--gender", + choices=("female", "male"), + default="female", + help="官网提供 female / male 两版,默认 female(国内梗多对应女版小人)", + ) + args = ap.parse_args() + gender = args.gender + out = ROOT / "static" / f"mbti-avatars-16personalities-{gender}" + out.mkdir(parents=True, exist_ok=True) + + readme = out / "README.txt" + warn = """16Personalities 彩色小人 SVG(官方静态站) + +版权归属:16personalities.com / NERIS Analytics Limited。 +请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。 + +文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。 +""" + readme.write_text(warn, encoding="utf-8") + + headers = { + "User-Agent": "SoulProjectMbtiAssetMirror/1.0 (+local dev backup)", + "Accept": "image/svg+xml,*/*", + } + + for code, role in SIXTEEN: + low = code.lower() + url = f"{BASE}/{low}-{role}-{gender}.svg{DEFAULT_QUERY}" + dest = out / f"{code}.svg" + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=60) as resp: + data = resp.read() + dest.write_bytes(data) + print(f"ok {dest.name} ({len(data)} bytes)") + + print(f"done -> {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/download_mbti_avatars_dicebear.py b/scripts/download_mbti_avatars_dicebear.py new file mode 100644 index 00000000..69c891af --- /dev/null +++ b/scripts/download_mbti_avatars_dicebear.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +下载 16 型 MBTI 占位头像(DiceBear notionists,见 https://www.dicebear.com/licenses/ )。 + +说明:网传「药水姐 / 紫大姐 / 大宝剑」等对应的 3D 小人多为 16personalities 或同人版权素材, +未获授权不宜批量爬取用于商用小程序;本脚本仅拉取开源生成器产物作占位,可后续在管理端替换为自有素材 URL。 +""" + +from __future__ import annotations + +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "static" / "mbti-avatars-dicebear" + +# 与社区常见梗名对应,便于文件夹内辨认(文件名仍用四字母类型) +MBTI_ORDER = [ + ("INTJ", "紫老头"), + ("INTP", "药水姐"), + ("ENTJ", "大姐头"), + ("ENTP", "骨折眉毛"), + ("INFJ", "绿老头"), + ("INFP", "小蝴蝶"), + ("ENFJ", "大宝剑"), + ("ENFP", "快乐小狗"), + ("ISTJ", "蓝老头"), + ("ISFJ", "小护士"), + ("ESTJ", "尺子姐"), + ("ESFJ", "雨伞哥"), + ("ISTP", "电钻哥"), + ("ISFP", "小画家"), + ("ESTP", "墨镜哥"), + ("ESFP", "沙锤姐"), +] + +BASE = "https://api.dicebear.com/7.x/notionists/svg" + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + readme = OUT / "README.txt" + lines = [ + "DiceBear 7.x notionists — 占位头像,可商用(以 dicebear.com 许可证为准)。", + "替换为「药水姐」等 3D 风素材时请使用自有版权或已授权图床 URL,在管理端 MBTI 头像库粘贴保存。", + "", + ] + for code, nick in MBTI_ORDER: + url = f"{BASE}?seed={code}" + dest = OUT / f"{code}.svg" + urllib.request.urlretrieve(url, dest) + lines.append(f"{code}.svg — {nick}") + print(f"ok {dest.name}") + readme.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"done -> {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_2026_daily_part.py b/scripts/fix_2026_daily_part.py new file mode 100644 index 00000000..9ad19a20 --- /dev/null +++ b/scripts/fix_2026_daily_part.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +将误挂在「第四篇 / 第9章」等下的 2026 派对场次,归位到「2026每日派对干货」篇章。 + +规则(与 content_upload.py 一致): +- id 为 10.xx 的节:只修正 part_id / chapter_id / part_title / chapter_title,不改 id。 +- section_title 含「第102场」及以后、且不在 part-2026-daily:同上修正(含 id 非 10.xx 的遗留)。 + +用法: + python3 scripts/fix_2026_daily_part.py # 预览 + python3 scripts/fix_2026_daily_part.py --execute # 执行 UPDATE +""" +from __future__ import annotations + +import argparse +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def load_db_config() -> dict: + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +PART_2026 = "part-2026-daily" +CHAPTER_2026 = "chapter-2026-daily" +TITLE_2026 = "2026每日派对干货" + + +def session_num(title: str) -> int | None: + m = re.search(r"第(\d+)场", title or "") + return int(m.group(1)) if m else None + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--execute", action="store_true") + args = p.parse_args() + + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + cfg = load_db_config() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + cur.execute( + """ + SELECT id, section_title, part_id, chapter_id, part_title, chapter_title, sort_order + FROM chapters + ORDER BY sort_order, id + """ + ) + rows = cur.fetchall() + to_fix: list[tuple] = [] + + def is_2026_daily_row(sid: str, title: str) -> bool: + """与上传脚本一致:10.01~10.99(两位小数段)、2026.1;不含 10.1/10.2 单段 id。""" + s = str(sid) + if s == "2026.1": + return True + if re.match(r"^10\.\d{2}$", s): + return True + n = session_num(title or "") + if n is not None and n >= 102: + return True + return False + + for r in rows: + sid, title, pid, cid, ptitle, ctitle, so = r + wrong_part = pid != PART_2026 or cid != CHAPTER_2026 + if wrong_part and is_2026_daily_row(sid, title or ""): + to_fix.append(r) + + if not to_fix: + print("没有需要归位的节(10.xx 或第102场及以后且已在 part-2026-daily)。") + conn.close() + return + + print(f"待归位到「{TITLE_2026}」: {len(to_fix)} 节\n") + for r in to_fix: + sid, title, pid, cid, _, _, _ = r + print(f" {sid}\tpart={pid}\tch={cid}\t{title[:60] if title else ''}") + + if not args.execute: + print("\n[预览] 未写入。确认无误后加 --execute") + conn.close() + return + + n = 0 + for r in to_fix: + sid = r[0] + cur.execute( + """ + UPDATE chapters SET + part_id = %s, + part_title = %s, + chapter_id = %s, + chapter_title = %s, + updated_at = NOW() + WHERE id = %s + """, + (PART_2026, TITLE_2026, CHAPTER_2026, TITLE_2026, sid), + ) + n += cur.rowcount + conn.commit() + conn.close() + print(f"\n已更新 {n} 行,part/chapter 已归位 {PART_2026} / {CHAPTER_2026}。") + + +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/miniprogram_upload.sh b/scripts/miniprogram_upload.sh index c9756607..45f49ee5 100755 --- a/scripts/miniprogram_upload.sh +++ b/scripts/miniprogram_upload.sh @@ -13,8 +13,8 @@ if [[ ! -x "$CLI" ]]; then exit 1 fi -# 未传参时默认 1.7.2(避免手滑打成 1.17 等与展示不一致) -DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.2}" +# 未传参时默认 1.7.3(避免手滑打成 1.17 等与展示不一致) +DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.3}" VERSION="${1:-$DEFAULT_VER}" DESC="${2:-版本 v$VERSION}" 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'
diff --git a/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx b/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx index 7150d949..ce73753e 100644 --- a/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx @@ -17,7 +17,7 @@ export function MentorTab() { 导师管理') + 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/scripts/wechat_miniprogram_release.py b/scripts/wechat_miniprogram_release.py index 86d825d9..48db5776 100644 --- a/scripts/wechat_miniprogram_release.py +++ b/scripts/wechat_miniprogram_release.py @@ -247,8 +247,8 @@ def main() -> None: p_up.add_argument( "--version", "-v", - default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"), - help="版本号,默认 1.7.2 或环境变量 MINIPROGRAM_DEFAULT_VERSION", + default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.3"), + help="版本号,默认 1.7.3 或环境变量 MINIPROGRAM_DEFAULT_VERSION", ) p_up.add_argument( "--desc", @@ -289,7 +289,7 @@ def main() -> None: p_uo.add_argument( "--version", "-v", - default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"), + default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.3"), ) p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>") @@ -300,7 +300,7 @@ def main() -> None: p_rel.add_argument( "--version", "-v", - default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"), + default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.3"), ) p_rel.add_argument("--desc", "-d", default="", help="上传说明,默认:版本 v<版本号>") p_rel.add_argument("--version-desc", default="", help="提交审核说明,默认同上传说明") 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/package.json b/soul-admin/package.json index d5ae9e91..1b73b261 100644 --- a/soul-admin/package.json +++ b/soul-admin/package.json @@ -51,6 +51,7 @@ "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.12.0", "picomatch": "4.0.2", + "playwright": "1.57.0", "postcss": "^8.4.49", "tailwindcss": "^4.1.9", "typescript": "~5.6.2", diff --git a/soul-admin/pnpm-lock.yaml b/soul-admin/pnpm-lock.yaml index d7afaefa..0ba1be63 100644 --- a/soul-admin/pnpm-lock.yaml +++ b/soul-admin/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: picomatch: specifier: 4.0.2 version: 4.0.2 + playwright: + specifier: 1.57.0 + version: 1.57.0 postcss: specifier: ^8.4.49 version: 8.5.6 @@ -1612,6 +1615,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1876,6 +1884,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -3607,6 +3625,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3809,6 +3830,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-value-parser@4.2.0: {} postcss@8.5.6: diff --git a/soul-admin/src/App.tsx b/soul-admin/src/App.tsx index 311b6ea1..cb5f6d90 100644 --- a/soul-admin/src/App.tsx +++ b/soul-admin/src/App.tsx @@ -1,53 +1,64 @@ +import { lazy, Suspense } from 'react' import { Routes, Route, Navigate } from 'react-router-dom' import { AdminLayout } from './layouts/AdminLayout' import { LoginPage } from './pages/login/LoginPage' -import { DashboardPage } from './pages/dashboard/DashboardPage' -import { OrdersPage } from './pages/orders/OrdersPage' -import { UsersPage } from './pages/users/UsersPage' -import { DistributionPage } from './pages/distribution/DistributionPage' -import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage' -import { ContentPage } from './pages/content/ContentPage' -import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage' -import { SettingsPage } from './pages/settings/SettingsPage' -import { PaymentPage } from './pages/payment/PaymentPage' -import { SitePage } from './pages/site/SitePage' -import { QRCodesPage } from './pages/qrcodes/QRCodesPage' -import { MatchPage } from './pages/match/MatchPage' -import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage' -import { VipRolesPage } from './pages/vip-roles/VipRolesPage' -import { MentorsPage } from './pages/mentors/MentorsPage' -import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage' -import { FindPartnerPage } from './pages/find-partner/FindPartnerPage' -import { ApiDocsPage } from './pages/api-docs/ApiDocsPage' import { NotFoundPage } from './pages/not-found/NotFoundPage' +const DashboardPage = lazy(() => import('./pages/dashboard/DashboardPage').then(m => ({ default: m.DashboardPage }))) +const OrdersPage = lazy(() => import('./pages/orders/OrdersPage').then(m => ({ default: m.OrdersPage }))) +const UsersPage = lazy(() => import('./pages/users/UsersPage').then(m => ({ default: m.UsersPage }))) +const DistributionPage = lazy(() => import('./pages/distribution/DistributionPage').then(m => ({ default: m.DistributionPage }))) +const WithdrawalsPage = lazy(() => import('./pages/withdrawals/WithdrawalsPage').then(m => ({ default: m.WithdrawalsPage }))) +const ContentPage = lazy(() => import('./pages/content/ContentPage').then(m => ({ default: m.ContentPage }))) +const ReferralSettingsPage = lazy(() => import('./pages/referral-settings/ReferralSettingsPage').then(m => ({ default: m.ReferralSettingsPage }))) +const SettingsPage = lazy(() => import('./pages/settings/SettingsPage').then(m => ({ default: m.SettingsPage }))) +const PaymentPage = lazy(() => import('./pages/payment/PaymentPage').then(m => ({ default: m.PaymentPage }))) +const SitePage = lazy(() => import('./pages/site/SitePage').then(m => ({ default: m.SitePage }))) +const QRCodesPage = lazy(() => import('./pages/qrcodes/QRCodesPage').then(m => ({ default: m.QRCodesPage }))) +const MatchPage = lazy(() => import('./pages/match/MatchPage').then(m => ({ default: m.MatchPage }))) +const MatchRecordsPage = lazy(() => import('./pages/match-records/MatchRecordsPage').then(m => ({ default: m.MatchRecordsPage }))) +const VipRolesPage = lazy(() => import('./pages/vip-roles/VipRolesPage').then(m => ({ default: m.VipRolesPage }))) +const MentorsPage = lazy(() => import('./pages/mentors/MentorsPage').then(m => ({ default: m.MentorsPage }))) +const MentorConsultationsPage = lazy(() => import('./pages/mentor-consultations/MentorConsultationsPage').then(m => ({ default: m.MentorConsultationsPage }))) +const FindPartnerPage = lazy(() => import('./pages/find-partner/FindPartnerPage').then(m => ({ default: m.FindPartnerPage }))) +const ApiDocsPage = lazy(() => import('./pages/api-docs/ApiDocsPage').then(m => ({ default: m.ApiDocsPage }))) + +function PageLoader() { + return ( ++ + 加载中... ++ ) +} + function App() { return (} /> }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> } /> - } /> - } /> - } /> + }> } /> + }> } /> + }> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> + }> } /> } /> - } /> + }> } /> } /> } /> diff --git a/soul-admin/src/api/client.ts b/soul-admin/src/api/client.ts index b16a6366..41819530 100644 --- a/soul-admin/src/api/client.ts +++ b/soul-admin/src/api/client.ts @@ -26,7 +26,7 @@ const getBaseUrl = (): string => { return DEFAULT_API_BASE } -/** 请求完整 URL:baseUrl + path,path 必须与现网一致(如 /api/orders) */ +/** 请求完整 URL:baseUrl + path,path 须与 soul-api 路由一致(如 /api/admin/orders) */ export function apiUrl(path: string): string { const base = getBaseUrl() const p = path.startsWith('/') ? path : `/${path}` @@ -40,7 +40,7 @@ export type RequestInitWithBody = RequestInit & { } /** - * 发起请求。path 为与现网一致的 API 路径(如 /api/admin、/api/orders)。 + * 发起请求。path 为与 soul-api 路由一致的 API 路径(如 /api/admin/orders、/api/db/users)。 * 若有 admin_token(JWT)则自动带 Authorization: Bearer;credentials: 'include' 保留以兼容需 Cookie 的接口。 */ export async function request ( 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 new file mode 100644 index 00000000..3ac56ad4 --- /dev/null +++ b/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx @@ -0,0 +1,1032 @@ +import toast from '@/utils/toast' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { useSearchParams } from 'react-router-dom' +import { normalizeImageUrl } from '@/lib/utils' +import { Card, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Search, RefreshCw, Trash2, UserPlus as LeadIcon } from 'lucide-react' +import { UserDetailModal } from '@/components/modules/user/UserDetailModal' +import { Pagination } from '@/components/ui/Pagination' +import { useDebounce } from '@/hooks/useDebounce' +import { get, post } from '@/api/client' + +export type CkbLeadRecord = { + id: number + userId?: string + userNickname?: string + userAvatar?: string + phone?: string + wechatId?: string + name?: string + source?: string + planApiKey?: string + personName?: string + pushStatus?: 'pending' | 'success' | 'failed' | string + retryCount?: number + ckbCode?: number + ckbMessage?: string + ckbData?: string + ckbError?: string + lastPushAt?: string + nextRetryAt?: string + createdAt?: string +} + +export type CkbLeadsPanelHandle = { + reload: () => void +} + +export type CkbLeadsPanelProps = { + /** 与 URL `leadAction` 同步(用户管理 / 推广中心各自路由下) */ + urlSyncLeadAction?: boolean + /** 面板可见时请求数据并允许轮询 */ + active?: boolean + /** 用户详情弹窗保存后的额外回调(如用户列表页需 refreshUsers) */ + onUserDetailUpdated?: () => void +} + +function prettyJson(raw: string): string { + const s = (raw || '').trim() + if (!s) return '' + try { + return JSON.stringify(JSON.parse(s), null, 2) + } catch { + return s + } +} + +/** 获客列表:头像 + 昵称,有 userId 时可点进用户详情 */ +function LeadUserNickCell({ + userId, + userAvatar, + nickname, + name, + onOpenDetail, +}: { + userId?: string + userAvatar?: string + nickname?: string + name?: string + onOpenDetail: (id: string) => void +}) { + const [imgFailed, setImgFailed] = useState(false) + const label = nickname || name || '-' + const initial = (label === '-' ? '?' : label).charAt(0) + const showImg = !!userAvatar?.trim() && !imgFailed + + return ( + ++ ) +} + +export const CkbLeadsPanel = forwardRef+ {showImg ? ( ++setImgFailed(true)} + /> + ) : ( + {initial} + )} +
{ + if (userId) onOpenDetail(userId) + }} + > + {label} + +( + function CkbLeadsPanel({ urlSyncLeadAction = false, active = true, onUserDetailUpdated }, ref) { + const [searchParams, setSearchParams] = useSearchParams() + + const [leadsRecords, setLeadsRecords] = useState ([]) + const [leadsTotal, setLeadsTotal] = useState(0) + const [leadsPage, setLeadsPage] = useState(1) + const [leadsPageSize] = useState(10) + const [leadsLoading, setLeadsLoading] = useState(false) + const [leadsError, setLeadsError] = useState (null) + const [leadsSearchTerm, setLeadsSearchTerm] = useState('') + const debouncedLeadsSearch = useDebounce(leadsSearchTerm, 300) + const [leadsSourceFilter, setLeadsSourceFilter] = useState('') + const [leadsActionFilter, setLeadsActionFilter] = useState('') + const [leadsPushStatusFilter, setLeadsPushStatusFilter] = useState('') + const [leadsDedupEnabled, setLeadsDedupEnabled] = useState(false) + const [leadsStats, setLeadsStats] = useState<{ + uniqueUsers?: number + sourceStats?: { source: string; cnt: number }[] + }>({}) + const [retryingLeadId, setRetryingLeadId] = useState (null) + const [deletingLeadId, setDeletingLeadId] = useState (null) + const [leadSelectedIds, setLeadSelectedIds] = useState ([]) + const [batchDeletingLeads, setBatchDeletingLeads] = useState(false) + const leadsHeaderCheckboxRef = useRef (null) + const [batchRetrying, setBatchRetrying] = useState(false) + const [showCkbDataDialog, setShowCkbDataDialog] = useState(false) + const [ckbDataDialogTitle, setCkbDataDialogTitle] = useState('存客宝返回 data') + const [ckbDataDialogContent, setCkbDataDialogContent] = useState('') + const [showDetailModal, setShowDetailModal] = useState(false) + const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState (null) + + const loadLeads = useCallback( + async (searchVal?: string, sourceVal?: string) => { + setLeadsLoading(true) + setLeadsError(null) + try { + const params = new URLSearchParams({ + mode: 'contact', + page: String(leadsPage), + pageSize: String(leadsPageSize), + }) + const s = searchVal ?? debouncedLeadsSearch + if (s) params.set('search', s) + const src = sourceVal ?? leadsSourceFilter + if (src) params.set('source', src) + if (leadsActionFilter) params.set('action', leadsActionFilter) + if (leadsPushStatusFilter) params.set('pushStatus', leadsPushStatusFilter) + const data = await get<{ + success?: boolean + records?: CkbLeadRecord[] + total?: number + stats?: { uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] } + error?: string + }>(`/api/db/ckb-leads?${params}`) + if (data?.success) { + setLeadsRecords(data.records || []) + setLeadsTotal(data.total ?? 0) + if (data.stats) setLeadsStats(data.stats) + } else { + const msg = data?.error || '加载获客列表失败' + setLeadsError(msg) + toast.error(msg) + setLeadsRecords([]) + setLeadsTotal(0) + } + } catch (e) { + const msg = e instanceof Error ? e.message : '网络错误' + setLeadsError(msg) + toast.error('加载获客列表失败: ' + msg) + setLeadsRecords([]) + setLeadsTotal(0) + } finally { + setLeadsLoading(false) + } + }, + [ + leadsPage, + leadsPageSize, + debouncedLeadsSearch, + leadsSourceFilter, + leadsActionFilter, + leadsPushStatusFilter, + ], + ) + + useImperativeHandle(ref, () => ({ reload: () => void loadLeads() }), [loadLeads]) + + useEffect(() => { + setLeadSelectedIds([]) + }, [debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter]) + + const leadActionParam = (searchParams.get('leadAction') || '').trim() + useEffect(() => { + if (!urlSyncLeadAction || !active) return + setLeadsActionFilter(leadActionParam) + }, [urlSyncLeadAction, active, leadActionParam]) + + type LeadRetryRecordPatch = { + pushStatus?: string + retryCount?: number + ckbCode?: number + ckbMessage?: string + ckbData?: string + ckbError?: string + lastPushAt?: string | null + nextRetryAt?: string | null + } + + function mergeLeadRowAfterRetry(row: CkbLeadRecord, rec: LeadRetryRecordPatch): CkbLeadRecord { + return { + ...row, + ...(rec.pushStatus !== undefined ? { pushStatus: rec.pushStatus } : {}), + ...(typeof rec.retryCount === 'number' ? { retryCount: rec.retryCount } : {}), + ...(typeof rec.ckbCode === 'number' ? { ckbCode: rec.ckbCode } : {}), + ...(rec.ckbMessage !== undefined ? { ckbMessage: rec.ckbMessage } : {}), + ...(rec.ckbData !== undefined ? { ckbData: rec.ckbData } : {}), + ...(rec.ckbError !== undefined ? { ckbError: rec.ckbError } : {}), + ...(rec.lastPushAt !== undefined ? { lastPushAt: rec.lastPushAt ?? undefined } : {}), + ...(rec.nextRetryAt !== undefined ? { nextRetryAt: rec.nextRetryAt ?? undefined } : {}), + } + } + + async function retryLeadPush(recordId: number) { + if (!recordId) return + setRetryingLeadId(recordId) + try { + const data = await post<{ + success?: boolean + pushed?: boolean + error?: string + record?: LeadRetryRecordPatch + }>('/api/db/ckb-leads/retry', { id: recordId }) + if (data?.success) { + toast.success(data.pushed ? '重推成功' : '已发起重推,请刷新查看状态') + if (data.record) { + setLeadsRecords((prev) => + prev.map((row) => + row.id === recordId ? mergeLeadRowAfterRetry(row, data.record!) : row, + ), + ) + } + } else { + toast.error(data?.error || '重推失败') + } + } catch (e) { + toast.error(e instanceof Error ? e.message : '重推请求失败') + } finally { + setRetryingLeadId(null) + } + } + + async function deleteLeadRecord(recordId: number) { + if (!recordId) return + if (!confirm('确定删除该条获客记录?删除后不可恢复。')) return + setDeletingLeadId(recordId) + try { + const data = await post<{ success?: boolean; error?: string }>('/api/db/ckb-leads/delete', { + id: recordId, + }) + if (data?.success) { + toast.success('已删除') + } else { + toast.error(data?.error || '删除失败') + } + } catch (e) { + toast.error(e instanceof Error ? e.message : '删除请求失败') + } finally { + setDeletingLeadId(null) + loadLeads() + } + } + + const { leadsRows, leadsRawCount, leadsDeduped } = useMemo(() => { + const normalizePhone = (p?: string | null) => (p || '').replace(/\D/g, '') || '' + const dedupKey = (r: CkbLeadRecord) => { + const phone = normalizePhone(r.phone) + if (phone) return `phone:${phone}` + const uid = (r.userId || '').trim() + if (uid) return `user:${uid}` + const wx = (r.wechatId || '').trim() + if (wx) return `wechat:${wx}` + return `row:${r.id}` + } + const q = debouncedLeadsSearch.trim().toLowerCase() + let rows = leadsRecords + if (q) { + rows = leadsRecords.filter((r) => { + const blob = [r.userNickname, r.name, r.phone, r.wechatId, r.personName, r.source, r.planApiKey] + .filter(Boolean) + .join(' ') + .toLowerCase() + return blob.includes(q) + }) + } + const sorted = [...rows].sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return tb - ta + }) + if (!leadsDedupEnabled) { + return { leadsRows: sorted, leadsRawCount: rows.length, leadsDeduped: 0 } + } + const seen = new Set () + const out: CkbLeadRecord[] = [] + for (const r of sorted) { + const k = dedupKey(r) + if (seen.has(k)) continue + seen.add(k) + out.push(r) + } + return { leadsRows: out, leadsRawCount: rows.length, leadsDeduped: rows.length - out.length } + }, [leadsRecords, debouncedLeadsSearch, leadsDedupEnabled]) + + async function retryFailedLeadsInPage() { + const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') + if (failedRows.length === 0) { + toast.info('当前页无失败记录') + return + } + setBatchRetrying(true) + let successCount = 0 + for (const row of failedRows) { + try { + const data = await post<{ + success?: boolean + pushed?: boolean + record?: LeadRetryRecordPatch + }>('/api/db/ckb-leads/retry', { id: row.id }) + if (data?.success && data.pushed) successCount++ + if (data?.success && data.record) { + const patch = data.record + setLeadsRecords((prev) => + prev.map((r) => (r.id === row.id ? mergeLeadRowAfterRetry(r, patch) : r)), + ) + } + } catch { + /* 单条失败继续 */ + } + } + setBatchRetrying(false) + const total = failedRows.length + toast.success(`批量重推完成:成功 ${successCount} / ${total}`) + } + + function exportFailedLeadsCsv() { + const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') + if (failedRows.length === 0) { + toast.info('当前筛选下无失败记录可导出') + return + } + const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"` + const headers = [ + 'ID', + '昵称', + '手机号', + '微信号', + '对应@人', + '计划Key', + '来源', + '推送状态', + '重试次数', + '失败原因', + '下次重试时间', + '创建时间', + ] + const lines = [headers.join(',')] + for (const r of failedRows) { + lines.push( + [ + esc(r.id), + esc(r.userNickname || r.name || ''), + esc(r.phone || ''), + esc(r.wechatId || ''), + esc(r.personName || ''), + esc(r.planApiKey || ''), + esc(r.source || ''), + esc(r.pushStatus || ''), + esc(typeof r.retryCount === 'number' ? r.retryCount : ''), + esc(r.ckbError || ''), + esc(r.nextRetryAt ? new Date(r.nextRetryAt).toLocaleString() : ''), + esc(r.createdAt ? new Date(r.createdAt).toLocaleString() : ''), + ].join(','), + ) + } + const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `获客失败清单-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success(`已导出失败清单(${failedRows.length} 条)`) + } + + useEffect(() => { + if (!active) return + void loadLeads() + }, [active, leadsPage, loadLeads]) + + useEffect(() => { + if (!active) return + const t = window.setInterval(() => { + loadLeads() + }, 30000) + return () => window.clearInterval(t) + }, [active, loadLeads]) + + useEffect(() => { + const pageIds = leadsRows.map((r) => r.id) + const n = pageIds.filter((id) => leadSelectedIds.includes(id)).length + const el = leadsHeaderCheckboxRef.current + if (el) { + el.indeterminate = n > 0 && n < pageIds.length + } + }, [leadsRows, leadSelectedIds]) + + function toggleLeadSelectAllOnPage() { + const pageIds = leadsRows.map((r) => r.id) + const allOn = pageIds.length > 0 && pageIds.every((id) => leadSelectedIds.includes(id)) + if (allOn) { + setLeadSelectedIds((prev) => prev.filter((id) => !pageIds.includes(id))) + } else { + setLeadSelectedIds((prev) => [...new Set([...prev, ...pageIds])]) + } + } + + function toggleLeadOne(id: number) { + setLeadSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + + async function batchDeleteLeadRecords() { + if (leadSelectedIds.length === 0) { + toast.info('请先勾选要删除的记录') + return + } + const n = leadSelectedIds.length + if (!confirm(`确定批量删除选中的 ${n} 条获客记录?删除后不可恢复。`)) return + const CHUNK = 500 + setBatchDeletingLeads(true) + try { + let totalDeleted = 0 + for (let i = 0; i < leadSelectedIds.length; i += CHUNK) { + const slice = leadSelectedIds.slice(i, i + CHUNK) + const data = await post<{ success?: boolean; deleted?: number; error?: string }>( + '/api/db/ckb-leads/delete-batch', + { ids: slice }, + ) + if (!data?.success) { + toast.error(data?.error || '批量删除失败') + return + } + totalDeleted += Number(data.deleted) || 0 + } + toast.success(`已删除 ${totalDeleted} 条`) + setLeadSelectedIds([]) + } catch (e) { + toast.error(e instanceof Error ? e.message : '批量删除请求失败') + } finally { + setBatchDeletingLeads(false) + loadLeads() + } + } + + const pushStatusBadge = (status?: string) => { + const s = status || '' + if (s === 'success') + return 已推送 + if (s === 'pending_verify') + return处理中 + if (s === 'expired') return已过期 + if (s === 'failed') return失败 + if (s === 'pending') + return待推送 + if (s) + return ( ++ {s} + + ) + return待推送 + } + + const failedReasonStats = useMemo(() => { + const map = new Map() + for (const r of leadsRows) { + if (r.pushStatus !== 'failed') continue + const reason = (r.ckbError || '未知错误').trim() || '未知错误' + map.set(reason, (map.get(reason) || 0) + 1) + } + return Array.from(map.entries()) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count) + }, [leadsRows]) + + async function copyFailedDebugInfo() { + const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') + if (failedRows.length === 0) { + toast.info('当前页无失败记录') + return + } + const topReasons = failedReasonStats.slice(0, 8).map((x) => `- ${x.reason}:${x.count} 条`).join('\n') + const recentIds = failedRows.slice(0, 30).map((r) => r.id).join(', ') + const detailRows = failedRows + .slice(0, 20) + .map( + (r) => + `#${r.id} | ${r.userNickname || r.name || '-'} | 手机:${r.phone || '-'} | 来源:${r.source || '-'} | 重试:${r.retryCount ?? 0} | 错误:${r.ckbError || '-'}`, + ) + .join('\n') + const text = [ + `【获客失败排障信息】`, + `时间:${new Date().toLocaleString()}`, + `当前页失败总数:${failedRows.length}`, + `主要失败原因:`, + topReasons || '- 无', + `最近失败记录ID(最多30条):${recentIds || '无'}`, + '', + `失败记录明细(最多20条):`, + detailRows || '无', + ].join('\n') + try { + await navigator.clipboard.writeText(text) + toast.success('已复制排障信息') + } catch { + toast.error('复制失败,请检查浏览器剪贴板权限') + } + } + + function syncLeadActionToUrl(v: string) { + if (!urlSyncLeadAction) return + setSearchParams((prev) => { + const p = new URLSearchParams(prev) + p.set('tab', 'leads') + if (v) p.set('leadAction', v) + else p.delete('leadAction') + return p + }) + } + + return ( + + {leadsError && ( ++ ) + }, +) + +CkbLeadsPanel.displayName = 'CkbLeadsPanel' diff --git a/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx b/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx index 612d668e..3a66795a 100644 --- a/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx +++ b/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx @@ -2,22 +2,46 @@ import { useState, useEffect, useCallback } from 'react' import { get, post } from '@/api/client' import toast from '@/utils/toast' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Smile, Save, RefreshCw, WandSparkles } from 'lucide-react' -import { - MBTI_TYPES_ORDERED, - MBTI_AVATAR_PROFILES, - buildMbtiSvgAvatarDataUrl, - type MbtiType, -} from '@/lib/mbtiAvatarPrompts' +import { Save, RefreshCw, UserRound } from 'lucide-react' +import { MBTI_TYPES_ORDERED, MBTI_AVATAR_PROFILES } from '@/lib/mbtiAvatarPrompts' +import { apiUrl } from '@/api/client' + +const LS_USER_GENDER_PACK = 'soul-mbti-user-gender' + +/** 网格预览与保存值:相对路径走 api 基址,data:/绝对 http 原样 */ +function mbtiAvatarImgSrc(raw: string): string { + const u = (raw ?? '').trim() + if (!u) return '' + if (u.startsWith('data:') || u.startsWith('http://') || u.startsWith('https://')) return u + const p = u.startsWith('/') ? u : `/${u}` + return apiUrl(p) +} + +function normalizeGenderPreference(raw: string | null): 'male' | 'female' | null { + if (raw == null || raw === '') return null + const s = raw.trim().toLowerCase() + if (s === 'male' || s === 'm' || s === '1' || s === '男') return 'male' + if (s === 'female' || s === 'f' || s === '2' || s === '女') return 'female' + return null +} + +/** 根据 localStorage 性别偏好选男/女包;未设置默认男版 */ +function resolvePackGender(): 'male' | 'female' { + return normalizeGenderPreference(localStorage.getItem(LS_USER_GENDER_PACK)) ?? 'male' +} + +function staticPngPathForType(t: string, gender: 'male' | 'female'): string { + return `/static/mbti-avatars-png-${gender}/${t}.png` +} export function MbtiAvatarsManager() { const [avatars, setAvatars] = useState+ {leadsError} ++ )} + {!leadsLoading && ( +setLeadsError(null)} aria-label="关闭"> + × + +++ )} + {!leadsLoading && failedReasonStats.length > 0 && ( +++线索总数
+{leadsTotal}
+++ {(leadsStats.sourceStats && leadsStats.sourceStats.length > 0 + ? leadsStats.sourceStats.slice(0, 2) + : [] + ).map((s) => ( +去重用户
++ {leadsStats.uniqueUsers ?? 0} +
+++ ))} +{s.source}
+{s.cnt}
++ + )} ++ +++失败聚合
++ 一键复制排障信息 + ++ {failedReasonStats.slice(0, 8).map((s) => ( +++ {s.reason} · {s.count} + + ))} +++ {!leadsLoading && leadsRows.length > 0 && ( +++++ {leadsStats.sourceStats && leadsStats.sourceStats.length > 0 && ( + + )} + + + ++ setLeadsSearchTerm(e.target.value)} + className="pl-9 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500" + /> + { + setLeadsPushStatusFilter('failed') + setLeadsPage(1) + }} + className="border-red-600/60 text-red-300 hover:bg-red-500/10 bg-transparent text-xs h-9" + > + 只看失败 + + + 本页 {leadsRawCount} 条{leadsDeduped > 0 ? ` · 已合并 ${leadsDeduped} 条重复` : ''} + ++++ 导出失败清单 + ++ ++ 重推本页失败项 + void batchDeleteLeadRecords()} + disabled={batchDeletingLeads || leadSelectedIds.length === 0 || leadsLoading} + className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent" + > + ++ {batchDeletingLeads ? '删除中…' : `批量删除(${leadSelectedIds.length})`} + loadLeads()} + disabled={leadsLoading} + className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent" + > + +刷新 + + 已选 {leadSelectedIds.length} 条 + {leadSelectedIds.length > 0 && ( +
+ )} +setLeadSelectedIds([])} + > + 清空 + + )} ++ + ++ {leadsLoading ? ( + +++ ) : ( ++ 加载中... + ++ )} ++
++ ++ ++ 0 && leadsRows.every((r) => leadSelectedIds.includes(r.id)) + } + onChange={toggleLeadSelectAllOnPage} + className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer" + title="全选本页(展示行)" + /> + +昵称 +手机号 +微信号 +@人 +计划Key +状态 +时间 +操作 ++ {leadsRows.map((r) => ( + ++ + ))} + {leadsRows.length === 0 && ( ++ toggleLeadOne(r.id)} + disabled={batchDeletingLeads} + className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer" + /> + ++ +{ + setSelectedUserIdForDetail(id) + setShowDetailModal(true) + }} + /> + {r.phone || '-'} +{r.wechatId || '-'} +{r.personName || '-'} ++ {(() => { + const k = (r.planApiKey || '').trim() + if (!k) return '-' + const masked = k.length <= 10 ? k : `${k.slice(0, 6)}…${k.slice(-4)}` + return ( + +{ + try { + await navigator.clipboard.writeText(k) + toast.success('已复制计划Key') + } catch { + toast.error('复制失败,请手动复制') + } + }} + > + {masked} + + ) + })()} ++ ++ {pushStatusBadge(r.pushStatus)} + {(typeof r.ckbCode === 'number' || (r.ckbMessage || '').trim()) && ( +++ {[ + typeof r.ckbCode === 'number' ? `code=${r.ckbCode}` : '', + (r.ckbMessage || '').trim() ? String(r.ckbMessage).trim() : '', + ] + .filter(Boolean) + .join(' · ')} +
+ )} + {!!(r.ckbData || '').trim() && ( +{ + const raw = String(r.ckbData || '').trim() + setCkbDataDialogTitle(`存客宝返回 data(#${r.id})`) + setCkbDataDialogContent(prettyJson(raw)) + setShowCkbDataDialog(true) + }} + > + 查看 data + + )} + {!!r.ckbError && ( ++ {r.ckbError} +
+ )} ++ {r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} + ++ ++++ {typeof r.retryCount === 'number' ? `第 ${r.retryCount} 次` : '-'} +
+++retryLeadPush(r.id)} + className="h-8 px-2.5 text-[11px] border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent shrink-0" + > + ++ 重推 + deleteLeadRecord(r.id)} + className="h-8 px-2.5 text-[11px] border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent shrink-0" + > + ++ 删除 + + + )} ++ ++++ 暂无获客线索
++ {debouncedLeadsSearch.trim() || leadsSourceFilter + ? '当前筛选无匹配记录,可清空后重试' + : '获客计划产生的留资会出现在此列表'} +
+loadLeads()} + disabled={leadsLoading} + className="border-[#38bdac]/40 text-[#38bdac] hover:bg-[#38bdac]/10 bg-transparent" + > + ++ 重新加载 + {}} + /> + setShowDetailModal(false)} + userId={selectedUserIdForDetail} + onUserUpdated={() => { + onUserDetailUpdated?.() + void loadLeads() + }} + /> + + + >({}) 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) @@ -25,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 { @@ -53,23 +78,23 @@ export function MbtiAvatarsManager() { } } - const generateOne = (type: MbtiType) => { - const dataUrl = buildMbtiSvgAvatarDataUrl(type) - setAvatars((prev) => ({ ...prev, [type]: dataUrl })) - toast.success(`${type} 已生成`) - } - - 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) } } @@ -84,86 +109,86 @@ export function MbtiAvatarsManager() { return ( -- +- -- -- MBTI 头像库 - - 采用人物化风格,按 MBTI 性格自动生成。头像内不显示中英文,仅显示人物形象,颜色与站点主题融合。 - -- -- -- {generating ? '生成中…' : '一键生成16头像'} - - -- 重新加载 - - -- {saving ? '保存中…' : '保存映射'} - +++ ++ {autoAssigning ? '分配中…' : '自动分配头像'} + + ++ {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('分配失败') + } + }} + > + 批量分配给无头像用户 + ++ ++ 刷新 + + 头像文件由 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 ? ( --- -{t} - - {meta.title} - --) })} diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index 4f7cc163..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 ? ( --- ) : ( - 未配 - )} -
- setAvatars((prev) => ({ ...prev, [t]: e.target.value }))} +- -+ {showPlaceholder ? ( + + {t} + + ) : ( +-setImgBroken((prev) => ({ ...prev, [t]: true }))} + onLoad={(e) => { + const el = e.target as HTMLImageElement + if (el.naturalWidth <= 0) setImgBroken((prev) => ({ ...prev, [t]: true })) + }} /> -
-+generateOne(t)} - > - 生成这张 - -setAvatars((prev) => ({ ...prev, [t]: '' }))} - > - 清空 - + )}{t} + + {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/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 }))} />-- -} + {subTab === 'booking' && setSubTab('manage')} />} {subTab === 'manage' && ( diff --git a/soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx b/soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx index c9c69c27..1dbe313a 100644 --- a/soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx @@ -1,140 +1,58 @@ -import toast from '@/utils/toast' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { RefreshCw, Send } from 'lucide-react' -import { Pagination } from '@/components/ui/Pagination' -import { get, post } from '@/api/client' +import { MatchRecordsByType } from './MatchRecordsByType' +import { get } from '@/api/client' -interface MatchRecord { - id: string; userId: string; matchedUserId: string; matchType: string - phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string - createdAt: string; ckbStatus?: string -} - -const typeLabels: Record = { - investor: '资源对接', mentor: '导师顾问', team: '团队招募', +interface TypeStats { + total: number + withContact: number } export function ResourceDockingTab() { - const [records, setRecords] = useState ([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const [isLoading, setIsLoading] = useState(true) - const [typeFilter, setTypeFilter] = useState('investor') - const [pushingId, setPushingId] = useState (null) + const [stats, setStats] = useState (null) - async function load() { - setIsLoading(true) + const loadStats = useCallback(async () => { try { - const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: typeFilter }) - const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`) - if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) } - } catch (e) { console.error(e) } - finally { setIsLoading(false) } - } + const data = await get<{ success?: boolean; records?: { phone?: string; wechatId?: string }[]; total?: number }>( + '/api/db/match-records?matchType=investor&page=1&pageSize=200' + ) + if (data?.success) { + const recs = data.records || [] + setStats({ + total: data.total ?? recs.length, + withContact: recs.filter(r => r.phone || r.wechatId).length, + }) + } + } catch { /* ignore */ } + }, []) - useEffect(() => { load() }, [page, typeFilter]) - - const pushToCKB = async (record: MatchRecord) => { - if (!record.phone && !record.wechatId) { - toast.info('该记录无联系方式,无法推送到存客宝') - return - } - setPushingId(record.id) - try { - const res = await post<{ success?: boolean; message?: string }>('/api/ckb/join', { - type: record.matchType || 'investor', - phone: record.phone || '', - wechat: record.wechatId || '', - userId: record.userId, - name: record.userNickname || '', - }) - toast.error(res?.message || (res?.success ? '推送成功' : '推送失败')) - } catch (e) { - toast.error('推送失败: ' + (e instanceof Error ? e.message : '网络错误')) - } finally { - setPushingId(null) - } - } - - - const totalPages = Math.ceil(total / pageSize) || 1 - const hasContact = (r: MatchRecord) => !!(r.phone || r.wechatId) + useEffect(() => { loadStats() }, [loadStats]) return ( - ----点击获客:有人填写手机号/微信号的直接显示,可一键推送到存客宝
-共 {total} 条记录 — 有联系方式的可触发存客宝添加好友
--+{ setTypeFilter(e.target.value); setPage(1) }} - className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"> - {Object.entries(typeLabels).map(([k, v]) => )} - -- -刷新 - +) } diff --git a/soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx b/soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx index 43337960..fb88cfb3 100644 --- a/soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx @@ -1,86 +1,32 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { RefreshCw } from 'lucide-react' -import { Pagination } from '@/components/ui/Pagination' +import { MatchRecordsByType } from './MatchRecordsByType' import { get } from '@/api/client' -interface MatchRecord { - id: string; userId: string; matchedUserId: string; matchType: string - phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string - createdAt: string -} - export function TeamRecruitTab() { - const [records, setRecords] = useState+- -+ ++ +资源对接总数
+{stats?.total ?? '-'}
++ ++ +有联系方式
+{stats?.withContact ?? '-'}
++ + +联系方式比例
++ {stats && stats.total > 0 ? `${((stats.withContact / stats.total) * 100).toFixed(0)}%` : '-'} +
+- +- {isLoading ? ( - -- ) : ( - <> -加载中... -
-- -- -发起人 -匹配到 -类型 -联系方式 -时间 -操作 -- {records.map(r => ( - -- - ))} - {records.length === 0 &&{r.userNickname || r.userId?.slice(0, 12)} -{r.matchedNickname || r.matchedUserId?.slice(0, 12)} -- -{typeLabels[r.matchType] || r.matchType} -- {r.phone && -📱 {r.phone}} - {r.wechatId &&💬 {r.wechatId}} - {!r.phone && !r.wechatId && 无联系方式} -{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} -- {hasContact(r) ? ( - -pushToCKB(r)} disabled={pushingId === r.id} - className="bg-[#38bdac] hover:bg-[#2da396] text-white text-xs h-7 px-3"> - - ) : ( - — - )} -- {pushingId === r.id ? '推送中...' : '推送CKB'} - } - 暂无记录 { setPageSize(n); setPage(1) }} /> - > - )} - ([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const [isLoading, setIsLoading] = useState(true) + const [total, setTotal] = useState (null) - async function load() { - setIsLoading(true) + const loadStats = useCallback(async () => { try { - const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'team' }) - const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`) - if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) } - } catch (e) { console.error(e) } - finally { setIsLoading(false) } - } + const data = await get<{ success?: boolean; total?: number }>('/api/db/match-records?matchType=team&page=1&pageSize=1') + if (data?.success) setTotal(data.total ?? 0) + } catch { /* ignore */ } + }, []) - useEffect(() => { load() }, [page]) - - const totalPages = Math.ceil(total / pageSize) || 1 + useEffect(() => { loadStats() }, [loadStats]) return ( - --- ---团队招募匹配记录,共 {total} 条
-用户通过「团队招募」提交联系方式到存客宝
-- -刷新 - - - {isLoading ? ( - - ) : ( - <> -加载中... -
-- -- -发起人 -匹配到 -联系方式 -时间 -- {records.map(r => ( - -- - ))} - {records.length === 0 &&{r.userNickname || r.userId} -{r.matchedNickname || r.matchedUserId} -- {r.phone && -📱 {r.phone}} - {r.wechatId &&💬 {r.wechatId}} - {!r.phone && !r.wechatId && '-'} -{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} -} - 暂无团队招募记录 { setPageSize(n); setPage(1) }} /> - > - )} + +) } diff --git a/soul-admin/src/pages/orders/OrdersPage.tsx b/soul-admin/src/pages/orders/OrdersPage.tsx index e0634cdf..fa4fc906 100644 --- a/soul-admin/src/pages/orders/OrdersPage.tsx +++ b/soul-admin/src/pages/orders/OrdersPage.tsx @@ -138,8 +138,8 @@ export function OrdersPage() { } return { name: desc, type: '章节购买' } } - if (type === 'fullbook' || desc.includes('全书')) { - return { name: '《一场Soul的创业实验》', type: '全书购买' } + if (type === 'fullbook' || desc.includes('读书会') || desc.includes('全书')) { + return { name: '《一场Soul的创业实验》365读书会', type: '365读书会' } } if (type === 'vip' || desc.includes('VIP')) { return { name: '超级个体开通费用', type: '超级个体' } @@ -150,7 +150,7 @@ export function OrdersPage() { return { name: desc, type: '其他' } } if (type === 'section') return { name: `章节 ${order.productId || order.sectionId || ''}`, type: '单章' } - if (type === 'fullbook') return { name: '《一场Soul的创业实验》', type: '全书' } + if (type === 'fullbook') return { name: '《一场Soul的创业实验》365读书会', type: '365读书会' } if (type === 'vip') return { name: '超级个体开通费用', type: '超级个体' } if (type === 'match') return { name: '找伙伴匹配', type: '功能' } return { name: '未知商品', type: type || '其他' } diff --git a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx index 0d6c7326..bab54761 100644 --- a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx +++ b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx @@ -1,14 +1,14 @@ import toast from '@/utils/toast' import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' import { Badge } from '@/components/ui/badge' -import { Save, Percent, Users, Wallet, Info } from 'lucide-react' +import { Save, Percent, Wallet } from 'lucide-react' import { get, post } from '@/api/client' interface ReferralConfig { @@ -104,48 +104,62 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe return (+ ++ ++团队招募总数
+{total ?? '-'}
+用户通过「团队招募」提交联系方式
-++--
- 推广 / 分销设置 + +
-+ 推广设置 - 统一管理「好友优惠」「你得 90% 收益」「绑定期 30 天」「提现门槛」等规则,小程序和 Web 共用这套配置(与系统设置中的「推广功能」开关配合:开关在{' '} - - 系统设置 → 功能开关 +
+ 佣金比例、绑定期、提现门槛( + + 功能开关 - )。 + )
- - {saving ? '保存中...' : '保存配置'} + + {saving ? '保存中...' : '保存'} ++++内容分成
+{config.distributorShare}%
+++VIP(VIP推广)
+{config.vipOrderShareVip}%
+++VIP(非VIP推广)
+{config.vipOrderShareNonVip}%
+++绑定/折扣
+{config.bindingDays}天 / {config.userDiscount}%
+- - + + -- 推广规则 - 这三项会直接体现在小程序「推广规则」卡片上,同时影响实收佣金计算。 - - -- ++ ++ --- 例如 5 表示好友立减 5%(在价格配置基础上生效)。 -
- -++ +--- 内容订单佣金 = 订单金额 ×{' '} - {config.distributorShare}% - ;会员订单见下方。 -
- ++ --- 推广者已是会员时,会员订单佣金比例,默认 20%。 -
- ++ --- 推广者非会员时,会员订单佣金比例,默认 10%。 -
- ++ -- 好友通过你的链接进来并登录后,绑定在你名下的天数。 -
- - + + -- 提现规则 - 与「提现中心」「自动提现」相关的参数,影响推广者看到的可提现金额和最低门槛。 - - -- +++ @@ -703,6 +735,13 @@ export function SettingsPage() {+++ --- 小程序「满 X 元可提现」展示的门槛,同时用于后端接口校验。 -
- ++ --- 批准提现时按此比例扣除后打款,如 5 表示申请 100 元实际到账 95 元。 -
-) diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index db74ec65..4249c1de 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -42,6 +42,7 @@ import { Sparkles, MessageSquare, Globe2, + TrendingUp, } from 'lucide-react' import { get, post } from '@/api/client' import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage' @@ -67,6 +68,7 @@ interface LocalSettings { distributorShare: number authorInfo: AuthorInfo ckbLeadApiKey: string + bookFreePreviewPercent: number } interface FeatureConfig { @@ -74,6 +76,7 @@ interface FeatureConfig { referralEnabled: boolean searchEnabled: boolean aboutEnabled: boolean + earningsLeaderboardEnabled: boolean } interface MpConfig { @@ -112,10 +115,11 @@ const defaultAuthor: AuthorInfo = { const defaultSettings: LocalSettings = { sectionPrice: 1, - baseBookPrice: 9.9, + baseBookPrice: 365, distributorShare: 90, authorInfo: { ...defaultAuthor }, ckbLeadApiKey: '', + bookFreePreviewPercent: 100, } const defaultFeatures: FeatureConfig = { @@ -123,6 +127,7 @@ const defaultFeatures: FeatureConfig = { referralEnabled: true, searchEnabled: true, aboutEnabled: true, + earningsLeaderboardEnabled: true, } const TAB_KEYS = ['system', 'author', 'admin', 'open-platform', 'api-docs'] as const @@ -169,7 +174,7 @@ export function SettingsPage() { const res = await get<{ success?: boolean featureConfig?: Partial- siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string } + siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string; bookFreePreviewPercent?: number } mpConfig?: Partial ossConfig?: Partial }>('/api/admin/settings') @@ -194,6 +199,7 @@ export function SettingsPage() { ...(typeof s.distributorShare === 'number' && { distributorShare: s.distributorShare }), ...(s.authorInfo && typeof s.authorInfo === 'object' && { authorInfo: { ...prev.authorInfo, ...s.authorInfo } }), ...(typeof s.ckbLeadApiKey === 'string' && { ckbLeadApiKey: s.ckbLeadApiKey }), + ...(typeof s.bookFreePreviewPercent === 'number' && { bookFreePreviewPercent: s.bookFreePreviewPercent }), })) } } catch (e) { @@ -273,6 +279,7 @@ export function SettingsPage() { distributorShare: localSettings.distributorShare, authorInfo: localSettings.authorInfo, ckbLeadApiKey: localSettings.ckbLeadApiKey || undefined, + bookFreePreviewPercent: localSettings.bookFreePreviewPercent, }, mpConfig: { ...mpConfig, @@ -621,7 +628,7 @@ export function SettingsPage() { /> -整本价格 (元) +365读书会价格 (元,对应 product fullbook) setLocalSettings((prev) => ({ ...prev, - baseBookPrice: Number.parseFloat(e.target.value) || 9.9, + baseBookPrice: Number.parseFloat(e.target.value) || 365, })) } />++++ + { + let v = Number.parseInt(e.target.value, 10) + if (Number.isNaN(v)) v = 100 + if (v < 0) v = 0 + if (v > 100) v = 100 + setLocalSettings((prev) => ({ ...prev, bookFreePreviewPercent: v })) + }} + /> ++ 免费展示比例 (%) + + 小程序目录中免费可见的章节占比(0~100%)。例如设置 80,则前 80% 章节免费展示,剩余需付费。设为 100 则全部免费可看目录。 +
+弹窗类文案在「弹窗文案」子 Tab 按页面路径 + 英文键维护(pagePopupItems);目录、Tab、首页板块等仍由其它配置决定。
++ 首页右上角「打赏 / 上麦」配置已按超级个体独立管理,请到{' '} + + 用户管理 → 超级个体 + + ,展开对应成员行即可配置。 +
@@ -912,6 +951,23 @@ export function SettingsPage() { onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)} />++++++ + 首页分享排行榜 + +控制首页分享排行榜的显示(推广能力分 Top 5)
+handleFeatureSwitch('earningsLeaderboardEnabled', checked)} + /> + @@ -940,6 +996,7 @@ export function SettingsPage() { { mod: '关于页面', ctrl: '关于页面开关', icon:
}, { mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: }, { mod: '超级个体名片', ctrl: '审核模式', icon: }, + { mod: '分享排行榜', ctrl: '分享排行榜开关', icon: }, { mod: '首页获客入口', ctrl: '已移除', icon: }, ].map((r) => ( diff --git a/soul-admin/src/pages/users/HomeEntryConfigTab.tsx b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx new file mode 100644 index 00000000..7d0ad6c3 --- /dev/null +++ b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx @@ -0,0 +1,602 @@ +import { useState, useCallback, useEffect } from 'react' +import toast from '@/utils/toast' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Gift, Mic, RefreshCw, Plus, Trash2 } from 'lucide-react' +import { get, post } from '@/api/client' + +type GiftRow = { + id: string + name: string + price: number + emoji: string + icon: string + /** 上麦横排从左到右,数字越小越靠左 */ + micOrder: number + /** 相对基础 emoji 尺寸倍数,默认 1 */ + iconScale: number + /** 默认支付档(上麦弹窗高亮 + 放大系数基准档) */ + micFeatured: boolean +} + +const DEFAULT_GIFTS: GiftRow[] = [ + { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '', micOrder: 0, iconScale: 1, micFeatured: false }, + { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '', micOrder: 1, iconScale: 1, micFeatured: true }, + { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '', micOrder: 2, iconScale: 1, micFeatured: false }, +] + +function asRecord(v: unknown): Record{ + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record ) : {} +} + +function normBool(v: unknown): boolean { + return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true' +} + +function parseGifts(raw: unknown): GiftRow[] { + if (!Array.isArray(raw)) return [...DEFAULT_GIFTS] + const out: GiftRow[] = [] + for (let i = 0; i < raw.length; i++) { + const g = asRecord(raw[i]) + const dFallback = DEFAULT_GIFTS[Math.min(i, DEFAULT_GIFTS.length - 1)] + const rn = String(g.name ?? '').trim() + let id = String(g.id ?? '').trim() + let d = dFallback + if (!id) { + const byName = rn ? DEFAULT_GIFTS.find((x) => x.name === rn) : undefined + if (byName) { + d = byName + id = byName.id + } else { + let mo = 999 + if (g.micOrder !== undefined && g.micOrder !== null && String(g.micOrder) !== '') { + const p = parseInt(String(g.micOrder), 10) + if (!Number.isNaN(p)) mo = p + } + if (mo !== 999) { + const byOrder = DEFAULT_GIFTS.find((x) => x.micOrder === mo) + if (byOrder) { + d = byOrder + id = byOrder.id + } + } + if (!id) { + d = dFallback + id = d.id + } + } + } + const name = String(g.name ?? d.name).trim() || d.name + let price = parseFloat(String(g.price ?? d.price)) + if (Number.isNaN(price) || price <= 0) price = d.price + let micOrder = 999 + if (g.micOrder !== undefined && g.micOrder !== null && String(g.micOrder) !== '') { + const p = parseInt(String(g.micOrder), 10) + if (!Number.isNaN(p)) micOrder = p + } + let iconScale = parseFloat(String(g.iconScale ?? '')) + if (Number.isNaN(iconScale) || iconScale <= 0) iconScale = 1 + const micFeatured = 'micFeatured' in g ? normBool(g.micFeatured) : false + out.push({ + id, + name, + price, + emoji: String(g.emoji ?? d.emoji).trim() || d.emoji, + icon: String(g.icon ?? '').trim(), + micOrder, + iconScale, + micFeatured, + }) + } + return out.length ? out : [...DEFAULT_GIFTS] +} + +export function HomeEntryConfigTab() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [baselineMpConfig, setBaselineMpConfig] = useState | null>(null) + + const [rewardEnabled, setRewardEnabled] = useState(false) + const [entryButtonText, setEntryButtonText] = useState('支持一下') + const [modalTitle, setModalTitle] = useState('打赏支持') + const [modalSubtitle, setModalSubtitle] = useState('感谢支持,任选礼物与数量') + const [payButtonText, setPayButtonText] = useState('立即支付') + const [leadLinkText, setLeadLinkText] = useState('留下联系方式') + const [afterPayToast, setAfterPayToast] = useState('支付成功,感谢支持!') + const [gifts, setGifts] = useState ([...DEFAULT_GIFTS]) + + const [micEnabled, setMicEnabled] = useState(false) + const [micStart, setMicStart] = useState('18:00') + const [micEnd, setMicEnd] = useState('21:00') + const [micEmoji, setMicEmoji] = useState('🎤') + const [micButtonText, setMicButtonText] = useState('申请上麦') + const [micModalTitle, setMicModalTitle] = useState('申请上麦') + const [micModalContent, setMicModalContent] = useState( + '当前为直播时段,提交后工作人员将按顺序处理连麦申请。', + ) + const [micAfterSubmitToast, setMicAfterSubmitToast] = useState('支付成功,感谢支持!') + const [paidWebhookUrl, setPaidWebhookUrl] = useState('') + /** 默认档 emoji / 图标再放大倍数(相对两侧),建议 1.45–1.8 */ + const [micCenterEmojiScale, setMicCenterEmojiScale] = useState(1.55) + + const applyFromMpConfig = useCallback((mp: Record ) => { + const mpUi = asRecord(mp.mpUi) + const home = asRecord(mpUi.homePage) + const rw = asRecord(home.linkKaruoReward) + const sch = asRecord(home.liveMicSchedule) + + setRewardEnabled(normBool(rw.enabled)) + setEntryButtonText(String(rw.entryButtonText || '支持一下')) + setModalTitle(String(rw.modalTitle || '打赏支持')) + setModalSubtitle(String(rw.modalSubtitle || '感谢支持,任选礼物与数量')) + setPayButtonText(String(rw.payButtonText || '立即支付')) + setLeadLinkText(String(rw.leadLinkText || '留下联系方式')) + setAfterPayToast(String(rw.afterPayToast || '支付成功,感谢支持!')) + setGifts(parseGifts(rw.gifts)) + + setMicEnabled(normBool(sch.enabled)) + setMicStart(String(sch.micStart || '18:00').trim() || '18:00') + setMicEnd(String(sch.micEnd || '21:00').trim() || '21:00') + setMicEmoji(String(sch.micEmoji || '🎤').trim() || '🎤') + setMicButtonText(String(sch.micButtonText || '申请上麦')) + setMicModalTitle(String(sch.micModalTitle || '申请上麦')) + setMicModalContent( + String(sch.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。'), + ) + setMicAfterSubmitToast(String(sch.micAfterSubmitToast || '支付成功,感谢支持!')) + setPaidWebhookUrl(String(sch.paidWebhookUrl || '').trim()) + { + const sc = parseFloat(String(sch.micCenterEmojiScale ?? '1.55')) + setMicCenterEmojiScale(!Number.isNaN(sc) && sc >= 1 ? Math.min(sc, 2.4) : 1.55) + } + }, []) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await get<{ + success?: boolean + mpConfig?: Record + }>('/api/admin/settings') + if (!res || res.success === false) { + toast.error('加载设置失败') + return + } + const mp = res.mpConfig && typeof res.mpConfig === 'object' ? { ...res.mpConfig } : {} + setBaselineMpConfig(mp) + applyFromMpConfig(mp) + } catch { + toast.error('加载设置失败') + } finally { + setLoading(false) + } + }, [applyFromMpConfig]) + + useEffect(() => { + void load() + }, [load]) + + const handleSave = async () => { + if (!baselineMpConfig) { + toast.error('请先等待配置加载完成') + return + } + setSaving(true) + try { + const mpUi = { ...asRecord(baselineMpConfig.mpUi) } + const prevHome = asRecord(mpUi.homePage) + const linkKaruoReward = { + ...asRecord(prevHome.linkKaruoReward), + enabled: rewardEnabled, + entryButtonText, + modalTitle, + modalSubtitle, + payButtonText, + leadLinkText, + afterPayToast, + gifts: gifts.map((g) => ({ + id: g.id, + name: g.name, + price: g.price, + emoji: g.emoji, + icon: g.icon || '', + micOrder: g.micOrder, + iconScale: g.iconScale, + micFeatured: g.micFeatured, + })), + } + const liveMicSchedule = { + ...asRecord(prevHome.liveMicSchedule), + enabled: micEnabled, + micStart, + micEnd, + micEmoji, + micButtonText, + micModalTitle, + micModalContent, + micAfterSubmitToast, + paidWebhookUrl: paidWebhookUrl.trim(), + micCenterEmojiScale, + } + mpUi.homePage = { + ...prevHome, + linkKaruoReward, + liveMicSchedule, + } + const mpConfig = { ...baselineMpConfig, mpUi } + const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', { + mpConfig, + }) + if (!res || res.success === false) { + toast.error(res?.error || '保存失败') + return + } + toast.success('已保存,小程序拉取配置后生效') + setBaselineMpConfig(mpConfig) + } catch (e) { + toast.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSaving(false) + } + } + + const updateGift = (index: number, patch: Partial ) => { + setGifts((rows) => rows.map((r, i) => (i === index ? { ...r, ...patch } : r))) + } + + const addGift = () => { + setGifts((rows) => [ + ...rows, + { + id: `gift_${Date.now()}`, + name: '新礼物', + price: 1, + emoji: '🎁', + icon: '', + micOrder: rows.length, + iconScale: 1, + micFeatured: false, + }, + ]) + } + + const removeGift = (index: number) => { + setGifts((rows) => rows.filter((_, i) => i !== index)) + } + + if (loading) { + return ( + ++ ) + } + + return ( ++ 加载首页入口配置… + ++ ) +} diff --git a/soul-admin/src/pages/users/SuperIndividualTab.tsx b/soul-admin/src/pages/users/SuperIndividualTab.tsx new file mode 100644 index 00000000..b5e1c0ec --- /dev/null +++ b/soul-admin/src/pages/users/SuperIndividualTab.tsx @@ -0,0 +1,769 @@ +import React, { useState, useCallback, useEffect } from 'react' +import toast from '@/utils/toast' +import { normalizeImageUrl } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog' +import { + Crown, Tag, ArrowUpDown, RefreshCw, Save, X, Gift, Trash2, Plus, + Pin, ChevronDown, ChevronRight, ChevronUp, Settings2, Webhook, Copy, Pencil, Stethoscope, +} from 'lucide-react' +import { get, put, post, del } from '@/api/client' +import { PersonAddEditModal, type PersonFormData } from '@/pages/content/PersonAddEditModal' +import { getPersonDetail } from '@/api/ckb' +import { UserDetailModal } from '@/components/modules/user/UserDetailModal' + +interface VipMember { + id: string + name: string + avatar?: string | null + mbti?: string | null + vipRole?: string | null + vipSort?: number | null + clickCount?: number | null + leadCount?: number | null + personId?: string | null + personToken?: string | null + isPinned?: boolean + personName?: string | null + personSource?: string | null + ckbPlanId?: number | null + ckbApiKey?: string | null + homeEntryConfig?: HomeEntryConfig | null + webhookUrl?: string | null +} + +interface GiftRow { id: string; name: string; price: number; emoji: string; icon: string } +interface HomeEntryConfig { + linkKaruoReward?: { + enabled?: boolean; entryButtonText?: string; modalTitle?: string + modalSubtitle?: string; payButtonText?: string; afterPayToast?: string + gifts?: GiftRow[] + } + liveMicSchedule?: { + enabled?: boolean; micStart?: string; micEnd?: string; micEmoji?: string + micButtonText?: string; paidWebhookUrl?: string + } +} + +const DEFAULT_GIFTS: GiftRow[] = [ + { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '' }, + { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '' }, + { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '' }, +] +const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手'] + +type CkbPlanHealth = { status: string; error?: string } + +function normBool(v: unknown): boolean { + return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true' +} +function avatarSrc(a?: string | null): string { return a ? normalizeImageUrl(a) : '' } + +export function SuperIndividualTab() { + const [members, setMembers] = useState+ 配置小程序首页右上角 + 入口:后台保存后,用户每次进入首页会拉最新配置 + (无需发版)。非直播时段走「链接 / 打赏」或置顶;直播时段入口可切为「申请上麦」,点击后打开 + 与上方相同的礼物支付弹窗,支付成功推送见下方 Webhook。 +
+ ++ + ++ ++ ++ 打赏弹窗(链接 / 支持) + + 非直播时段或未开启「定时上麦」时,与置顶 @ 人物逻辑并存:有置顶时优先展示置顶链接文案;仅开启打赏且无置顶时展示入口按钮文案。 + ++ +++++开启打赏弹窗 +关闭后仅保留置顶人物「点击链接」行为(若有置顶)
++ ++ +++入口按钮文案 + setEntryButtonText(e.target.value)} + /> +++弹窗标题 + setModalTitle(e.target.value)} + /> +++弹窗副标题 + setModalSubtitle(e.target.value)} + /> +++支付按钮文案 + setPayButtonText(e.target.value)} + /> +++留资链接文案 + setLeadLinkText(e.target.value)} + /> +++支付成功提示 + setAfterPayToast(e.target.value)} + /> +++++礼物档位 ++ +添加 + + 上麦横排:排序数字越小越靠左;图标缩放×相对基础大小;仅一条可设为「默认支付档」(高亮 + 与下方「默认档放大倍数」相乘)。 +
++ {gifts.map((g, i) => ( ++++ ))} ++ id + updateGift(i, { id: e.target.value })} + /> +++ 名称 + updateGift(i, { name: e.target.value })} + /> +++ 价格 + updateGift(i, { price: parseFloat(e.target.value) || 0 })} + /> +++ emoji + updateGift(i, { emoji: e.target.value })} + /> ++++removeGift(i)} + disabled={gifts.length <= 1} + > + ++ + 图标 URL(可选) + updateGift(i, { icon: e.target.value })} + placeholder="https://..." + /> +++ 上麦排序 + updateGift(i, { micOrder: parseInt(e.target.value, 10) || 0 })} + /> +++ 图标缩放× + + updateGift(i, { iconScale: parseFloat(e.target.value) || 1 }) + } + /> ++++{ + setGifts((rows) => + rows.map((r, j) => + j === i ? { ...r, micFeatured: v } : v ? { ...r, micFeatured: false } : r, + ), + ) + }} + /> + 默认支付档 + + + ++ ++ ++ 直播时段 · 申请上麦 + + 在时段内右上角显示配置的入口文案与图标;用户点击后弹出礼物选择 + 微信支付(礼物列表与上图「打赏弹窗」共用)。支付成功后订单推送:若填写了「上麦支付 + Webhook」则推到该地址,否则与系统设置里订单支付默认 Webhook(order_paid_webhook_url / + 获客 ckb_lead_webhook_url)一致。 + ++ +++++启用定时上麦入口 +例:18:00–21:00 显示上麦入口,点击走礼物支付;其余时间显示打赏或置顶
++ ++++开始时间 + setMicStart(e.target.value)} + /> +++结束时间 + setMicEnd(e.target.value)} + /> +++入口图标(emoji) + setMicEmoji(e.target.value)} + placeholder="🎤" + /> +++按钮文案 + setMicButtonText(e.target.value)} + /> +++弹窗标题 + setMicModalTitle(e.target.value)} + /> +++弹窗说明 +++默认档图标放大倍数 + setMicCenterEmojiScale(parseFloat(e.target.value) || 1.55)} + /> +仅作用于「默认支付档」相对两侧的 emoji/图标大小,与礼物行的「图标缩放」相乘。
+++支付成功 Toast(上麦入口礼物支付后) + setMicAfterSubmitToast(e.target.value)} + /> +++上麦支付 Webhook(可选) + setPaidWebhookUrl(e.target.value)} + /> ++ 仅当用户从直播时段上麦入口完成礼物支付时推送到此地址;留空则走订单默认群 Webhook(与「打赏」同一套兜底)。 +
+++void load()} + variant="outline" + className="border-gray-600 text-gray-300" + disabled={saving} + > + ++ 重新加载 + void handleSave()} + disabled={saving} + className="bg-[#38bdac] hover:bg-[#2da396] text-white" + > + {saving ? '保存中…' : '保存到小程序配置'} + +([]) + const [loading, setLoading] = useState(false) + const [expandedId, setExpandedId] = useState (null) + const [draggingId, setDraggingId] = useState (null) + const [dragOverId, setDragOverId] = useState (null) + + const [showRoleModal, setShowRoleModal] = useState(false) + const [roleTarget, setRoleTarget] = useState (null) + const [roleInput, setRoleInput] = useState('') + const [roleSaving, setRoleSaving] = useState(false) + + const [showSortModal, setShowSortModal] = useState(false) + const [sortTarget, setSortTarget] = useState (null) + const [sortInput, setSortInput] = useState('') + const [sortSaving, setSortSaving] = useState(false) + + const [showWebhookModal, setShowWebhookModal] = useState(false) + const [webhookTarget, setWebhookTarget] = useState (null) + const [webhookInput, setWebhookInput] = useState('') + const [webhookSaving, setWebhookSaving] = useState(false) + + const [personModalOpen, setPersonModalOpen] = useState(false) + const [editingPersonData, setEditingPersonData] = useState (null) + const [deleteTarget, setDeleteTarget] = useState (null) + + const [userDetailId, setUserDetailId] = useState (null) + + const [leadDetailOpen, setLeadDetailOpen] = useState(false) + const [leadDetailName, setLeadDetailName] = useState('') + const [leadDetailRecords, setLeadDetailRecords] = useState ([]) + const [leadDetailLoading, setLeadDetailLoading] = useState(false) + + /** personId -> 最近一次存客宝计划探测结果(与 /api/admin/ckb/plan-check 对齐) */ + const [planHealthByPerson, setPlanHealthByPerson] = useState >({}) + const [planCheckLoading, setPlanCheckLoading] = useState(false) + const [planCheckRowPid, setPlanCheckRowPid] = useState (null) + + const mergePlanCheckResults = useCallback((plans: { personId?: string; status?: string; error?: string }[]) => { + setPlanHealthByPerson((prev) => { + const next = { ...prev } + for (const p of plans) { + const pid = (p.personId || '').trim() + if (!pid) continue + next[pid] = { status: p.status || 'unknown', error: typeof p.error === 'string' ? p.error : undefined } + } + return next + }) + }, []) + + const checkAllCkbPlans = async () => { + setPlanCheckLoading(true) + try { + const d = await get<{ + success?: boolean + error?: string + plans?: { personId?: string; status?: string; error?: string }[] + message?: string + }>('/api/admin/ckb/plan-check') + if (!d?.success) { + toast.error(d?.error || '检测失败') + return + } + mergePlanCheckResults(d.plans || []) + const n = (d.plans || []).length + toast.success(n > 0 ? `已检测 ${n} 个存客宝计划` : (d.message || '无待检测计划')) + } catch (e) { + toast.error(e instanceof Error ? e.message : '检测请求失败') + } finally { + setPlanCheckLoading(false) + } + } + + const checkOneCkbPlan = async (personId: string) => { + const pid = personId.trim() + if (!pid) return + setPlanCheckRowPid(pid) + try { + const d = await get<{ + success?: boolean + error?: string + plans?: { personId?: string; status?: string; error?: string }[] + message?: string + }>(`/api/admin/ckb/plan-check?personId=${encodeURIComponent(pid)}`) + if (!d?.success) { + toast.error(d?.error || '检测失败') + return + } + mergePlanCheckResults(d.plans || []) + const row = (d.plans || [])[0] + if (row?.status === 'online') toast.success('存客宝计划连通正常') + else if (row?.status === 'error') toast.error(row.error || '存客宝计划异常') + else toast.info(d.message || '无返回') + } catch (e) { + toast.error(e instanceof Error ? e.message : '检测请求失败') + } finally { + setPlanCheckRowPid(null) + } + } + + const openLeadDetail = async (m: VipMember) => { + if (!m.personToken) return + setLeadDetailName(m.name) + setLeadDetailOpen(true) + setLeadDetailLoading(true) + try { + const d = await get<{ success?: boolean; records?: any[] }>(`/api/db/ckb-leads?token=${encodeURIComponent(m.personToken)}&limit=50`) + setLeadDetailRecords(d?.records || []) + } catch { setLeadDetailRecords([]) } finally { setLeadDetailLoading(false) } + } + + const load = useCallback(async () => { + setLoading(true) + try { + const d = await get<{ success?: boolean; data?: VipMember[] }>('/api/db/vip-members?limit=500') + if (d?.success && d.data) { + setMembers( + [...d.data] + .map((m, i) => ({ ...m, vipSort: typeof m.vipSort === 'number' ? m.vipSort : i + 1 })) + .sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999)), + ) + } + } catch { toast.error('加载失败') } finally { setLoading(false) } + }, []) + + useEffect(() => { void load() }, [load]) + + const togglePin = async (m: VipMember) => { + if (!m.personId) { toast.error('未关联@人物'); return } + try { + const r = await put<{ success?: boolean; isPinned?: boolean }>('/api/db/persons/pin', { personId: m.personId, isPinned: !m.isPinned }) + if (r?.success) { toast.success(r.isPinned ? '已置顶' : '已取消置顶'); await load() } + } catch { toast.error('操作失败') } + } + + const saveRole = async () => { + const v = roleInput.trim() + if (!roleTarget || !v) return + setRoleSaving(true) + try { + const r = await put<{ success?: boolean }>('/api/db/users', { id: roleTarget.id, vipRole: v }) + if (r?.success) { toast.success('已更新'); setShowRoleModal(false); await load() } + } catch { toast.error('失败') } finally { setRoleSaving(false) } + } + + const saveSort = async () => { + if (!sortTarget) return + const n = Number(sortInput) + if (!Number.isFinite(n)) { toast.error('请输入数字'); return } + setSortSaving(true) + try { + const r = await put<{ success?: boolean }>('/api/db/users', { id: sortTarget.id, vipSort: n }) + if (r?.success) { toast.success('已更新'); setShowSortModal(false); await load() } + } catch { toast.error('失败') } finally { setSortSaving(false) } + } + + const saveWebhook = async () => { + if (!webhookTarget) return + setWebhookSaving(true) + try { + const r = await put<{ success?: boolean }>('/api/db/vip-members/webhook', { userId: webhookTarget.id, webhookUrl: webhookInput.trim() }) + if (r?.success) { toast.success('Webhook 已保存'); setShowWebhookModal(false); await load() } + } catch { toast.error('失败') } finally { setWebhookSaving(false) } + } + + const handleDragStart = (e: React.DragEvent, id: string) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); setDraggingId(id) } + const handleDragOver = (e: React.DragEvent, id: string) => { e.preventDefault(); if (dragOverId !== id) setDragOverId(id) } + const handleDragEnd = () => { setDraggingId(null); setDragOverId(null) } + const handleDrop = async (e: React.DragEvent, toId: string) => { + e.preventDefault() + const fromId = e.dataTransfer.getData('text/plain') || draggingId + setDraggingId(null); setDragOverId(null) + if (!fromId || fromId === toId) return + const f = members.find(m => m.id === fromId), t = members.find(m => m.id === toId) + if (!f || !t) return + const fs = f.vipSort ?? members.indexOf(f) + 1, ts = t.vipSort ?? members.indexOf(t) + 1 + setMembers(prev => { + const n = [...prev]; const fi = n.findIndex(m => m.id === fromId); const ti = n.findIndex(m => m.id === toId) + if (fi === -1 || ti === -1) return prev + n[fi] = { ...prev[ti], vipSort: fs }; n[ti] = { ...prev[fi], vipSort: ts }; return n + }) + try { + await Promise.all([put('/api/db/users', { id: fromId, vipSort: ts }), put('/api/db/users', { id: toId, vipSort: fs })]) + toast.success('排序已更新'); await load() + } catch { toast.error('排序失败'); await load() } + } + + const copyToken = (t: string) => { navigator.clipboard.writeText(t).then(() => toast.success('Token 已复制')).catch(() => toast.error('复制失败')) } + + const openEditPerson = async (m: VipMember) => { + if (!m.personId) { toast.error('未关联@人物'); return } + try { + const res = await getPersonDetail(m.personId) + if (res?.success && res.person) { + const d = res.person + setEditingPersonData({ + id: d.token ?? d.personId, + personId: d.personId, + name: d.name, + personSource: (d as any).personSource ?? '', + userId: d.userId ?? '', + label: d.label ?? '', + ckbApiKey: d.ckbApiKey ?? '', + remarkType: d.remarkType, + remarkFormat: d.remarkFormat, + addFriendInterval: d.addFriendInterval, + startTime: d.startTime, + endTime: d.endTime, + deviceGroups: d.deviceGroups, + }) + } else { + setEditingPersonData({ id: m.personToken, personId: m.personId, name: m.name, userId: m.id }) + } + } catch { + setEditingPersonData({ id: m.personToken, personId: m.personId, name: m.name, userId: m.id }) + } + setPersonModalOpen(true) + } + + const handlePersonSubmit = async (data: PersonFormData) => { + const dgStr = String(data.deviceGroups || '').trim() + const deviceGroups = dgStr + ? dgStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n)) + : [] + const payload = { + personId: data.personId || (data.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)), + name: data.name, + userId: data.boundUserId, + aliases: data.aliases || undefined, + label: data.label, + ckbApiKey: data.ckbApiKey || undefined, + greeting: data.greeting || undefined, + tips: data.tips || undefined, + remarkType: data.remarkType || undefined, + remarkFormat: data.remarkFormat || undefined, + addFriendInterval: data.addFriendInterval || 1, + startTime: data.startTime || '09:00', + endTime: data.endTime || '18:00', + deviceGroups, + } + const res = await post<{ success?: boolean; error?: string }>('/api/db/persons', payload) + if (!res?.success) throw new Error(res?.error || '操作失败') + await load() + toast.success(editingPersonData ? '已保存' : '已添加') + } + + const confirmDeletePerson = async () => { + if (!deleteTarget?.personId) return + try { + await del(`/api/db/persons?personId=${deleteTarget.personId}`) + toast.success('已删除') + setDeleteTarget(null) + await load() + } catch { toast.error('删除失败') } + } + + return ( + ++ ) +} + +/** 展开面板:Token 信息 + 首页入口配置(打赏 + 上麦) */ +function ExpandedPanel({ member: m, onSaved, onCopyToken, onEditRole, onEditSort, onEditWebhook }: { + member: VipMember; onSaved: () => void; onCopyToken: (t: string) => void + onEditRole: () => void; onEditSort: () => void; onEditWebhook: () => void +}) { + const cfg = m.homeEntryConfig || {} + const rw = cfg.linkKaruoReward || {} + const sch = cfg.liveMicSchedule || {} + + const [rewardEnabled, setRewardEnabled] = useState(normBool(rw.enabled)) + const [entryText, setEntryText] = useState(rw.entryButtonText || '支持一下') + const [modalTitle, setModalTitle] = useState(rw.modalTitle || '打赏支持') + const [modalSub, setModalSub] = useState(rw.modalSubtitle || '感谢支持,任选礼物与数量') + const [payBtn, setPayBtn] = useState(rw.payButtonText || '立即支付') + const [afterToast, setAfterToast] = useState(rw.afterPayToast || '支付成功,感谢支持!') + const [gifts, setGifts] = useState++ + {loading ? ( +以超级个体为核心:排序、标签、置顶、打赏/上麦、Webhook、@人物管理。拖拽排序。
+++{ setEditingPersonData(null); setPersonModalOpen(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"> + +添加@人物 + + +刷新 + void checkAllCkbPlans()} + disabled={planCheckLoading || loading} + className="border-emerald-600/50 text-emerald-400 hover:bg-emerald-500/10 bg-transparent" + title="调用存客宝开放接口,对全部已配置计划的 @人物 做一次连通性检测(会尝试启用计划)" + > + ++ 存客宝检测 + + ) : members.length === 0 ? ( +加载中... + ) : ( +当前没有有效的超级个体。
+ + )} + + {/* 标签弹窗 */} + + + {/* 排序弹窗 */} + + + {/* Webhook 弹窗 */} + + + {/* 添加/编辑@人物 */} ++ ++
++ ++ ++ # +成员 +📌 +获客 +点击 +计划 +操作 ++ {members.map((m, idx) => { + const ex = expandedId === m.id + const hasPlan = !!m.ckbPlanId + return ( + ++ + ) + })} +handleDragStart(e, m.id)} onDragOver={e => handleDragOver(e, m.id)} + onDrop={e => handleDrop(e, m.id)} onDragEnd={handleDragEnd} + className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${draggingId === m.id ? 'opacity-60' : ''} ${dragOverId === m.id ? 'bg-[#38bdac]/10' : ''} ${m.isPinned ? 'bg-amber-500/5' : ''}`} + > + + {ex && ( ++ +setExpandedId(ex ? null : m.id)}> + {ex ? +: } + {idx + 1} ++ ++ {avatarSrc(m.avatar) ? ( ++{ (e.target as HTMLImageElement).style.display = 'none' }} /> + ) : ( +
{m.name?.[0] || '创'}+ )} +++setUserDetailId(m.id)} title="查看用户详情">{m.name} + {m.vipRole && {m.vipRole}} ++ +togglePin(m)} title={m.isPinned ? '取消置顶' : '置顶到首页'}> + ++ + +0 ? 'text-green-400 hover:text-green-300 hover:underline' : 'text-gray-600'}`} + onClick={() => { if ((m.leadCount ?? 0) > 0) openLeadDetail(m) }} + title={(m.leadCount ?? 0) > 0 ? '点击查看获客详情' : '暂无获客'} + >{m.leadCount ?? 0} +{m.clickCount ?? 0} ++ {!hasPlan ? ( + — + ) : !m.personId ? ( + +已配置 + ) : ( ++ {(() => { + const h = planHealthByPerson[m.personId] + if (!h) { + return ( ++ )} ++ 未检测 + + ) + } + if (h.status === 'online') { + return在线 + } + if (h.status === 'error') { + return ( ++ 异常 + + ) + } + return未知 + })()} +{ + e.stopPropagation() + void checkOneCkbPlan(m.personId!) + }} + > + ++ + +++setExpandedId(ex ? null : m.id)} title="展开配置"> + {m.personId &&openEditPerson(m)} title="编辑"> } + {m.personId &&setDeleteTarget(m)} title="删除"> } ++ + )} ++ +{ setRoleTarget(m); setRoleInput(m.vipRole || ''); setShowRoleModal(true) }} + onEditSort={() => { setSortTarget(m); setSortInput(m.vipSort != null ? String(m.vipSort) : ''); setShowSortModal(true) }} + onEditWebhook={() => { setWebhookTarget(m); setWebhookInput(m.webhookUrl || ''); setShowWebhookModal(true) }} + /> + + + {/* 删除确认 */} + + + {/* 用户详情弹窗 */} + setUserDetailId(null)} userId={userDetailId ?? null} onUserUpdated={load} /> + + {/* 获客详情弹窗 */} + + ( + Array.isArray(rw.gifts) && rw.gifts.length > 0 + ? rw.gifts.map((g: any) => { + let id = String(g.id || '').trim() + const nm = String(g.name || '').trim() + if (!id && nm) { + const hit = DEFAULT_GIFTS.find((x) => x.name === nm) + if (hit) id = hit.id + } + return { id, name: nm || '礼物', price: Number(g.price) || 1, emoji: String(g.emoji || '🎁'), icon: String(g.icon || '') } + }) + : [...DEFAULT_GIFTS], + ) + const [micOn, setMicOn] = useState(normBool(sch.enabled)) + const [micStart, setMicStart] = useState(sch.micStart || '18:00') + const [micEnd, setMicEnd] = useState(sch.micEnd || '21:00') + const [micEmoji, setMicEmoji] = useState(sch.micEmoji || '🎤') + const [micBtn, setMicBtn] = useState(sch.micButtonText || '申请上麦') + const [micWh, setMicWh] = useState(sch.paidWebhookUrl || '') + const [saving, setSaving] = useState(false) + + const swapGifts = (i: number, j: number) => { + setGifts((rows) => { + if (i < 0 || j < 0 || i >= rows.length || j >= rows.length) return rows + const next = [...rows] + ;[next[i], next[j]] = [next[j], next[i]] + return next + }) + } + + const save = async () => { + setSaving(true) + try { + const hec: HomeEntryConfig = { + linkKaruoReward: { + enabled: rewardEnabled, + entryButtonText: entryText, + modalTitle, + modalSubtitle: modalSub, + payButtonText: payBtn, + afterPayToast: afterToast, + gifts: gifts.map((g) => { + let id = String(g.id || '').trim() + if (!id) { + const hit = DEFAULT_GIFTS.find((x) => x.name === String(g.name || '').trim()) + id = hit ? hit.id : '' + } + return { id, name: g.name, price: g.price, emoji: g.emoji, icon: g.icon } + }), + }, + liveMicSchedule: { enabled: micOn, micStart, micEnd, micEmoji, micButtonText: micBtn, paidWebhookUrl: micWh.trim() }, + } + const r = await put<{ success?: boolean; error?: string }>('/api/db/persons/home-entry-config', { userId: m.id, homeEntryConfig: hec }) + if (!r?.success) { toast.error(r?.error || '保存失败'); return } + toast.success('首页入口配置已保存') + onSaved() + } catch { toast.error('保存失败') } finally { setSaving(false) } + } + + if (!m.personId) { + return 该会员尚未关联@人物,首页入口配置不可用。+ } + + return ( ++ {/* 快捷操作行 + Token */} ++ ) +} + +function Fld({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) { + return ( +++ ++ Token: ++{m.personToken}+onCopyToken(m.personToken || '')} title="复制"> + {m.isPinned &&当前置顶 } + {m.personSource === 'vip_sync' &&同步 } ++++ +{m.vipRole || '设标签'} + + +排序 {m.vipSort ?? '—'} + + +{m.webhookUrl?.trim() ? '已配置' : 'Webhook'} + + {/* 打赏 */} ++ +++ + {/* 上麦 */} +++ {rewardEnabled && ( +打赏弹窗 + + ++ )} ++++ + + +++ + +++ 礼物列表(上下箭头调整顺序) ++ {gifts.map((g, i) => ( +setGifts(r => [...r, { id: `gift_${Date.now()}`, name: '新礼物', price: 1, emoji: '🎁', icon: '' }])}>+ 添加 +++ ))} +++ setGifts(r => r.map((x, j) => j === i ? { ...x, emoji: e.target.value } : x))} /> + setGifts(r => r.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /> + setGifts(r => r.map((x, j) => j === i ? { ...x, price: parseFloat(e.target.value) || 0 } : x))} /> +swapGifts(i, i - 1)}> + ++ swapGifts(i, i + 1)}> + ++ setGifts(r => r.filter((_, j) => j !== i))} disabled={gifts.length <= 1}> ++++ 🎤 直播时段 · 上麦 ++ {micOn && ( ++ ++ )} ++++ + + + + 上麦支付 Webhook(可选) + setMicWh(e.target.value)} /> +++{saving ? '保存中...' : '保存配置'} + {label} + onChange(e.target.value)} placeholder={placeholder} /> ++ ) +} diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 981b562d..04437576 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -1,6 +1,5 @@ import toast from '@/utils/toast' -import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { normalizeImageUrl } from '@/lib/utils' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' @@ -38,22 +37,22 @@ import { BookOpen, Settings, PenLine, - Navigation, TrendingUp, ArrowUpDown, ChevronDown, ChevronUp, Crown, - Tag, Zap, - UserPlus as LeadIcon, + Link2, } from 'lucide-react' import { UserDetailModal } from '@/components/modules/user/UserDetailModal' import { MbtiAvatarsManager } from '@/components/modules/mbti/MbtiAvatarsManager' import { Pagination } from '@/components/ui/Pagination' import { useDebounce } from '@/hooks/useDebounce' -import { useSearchParams } from 'react-router-dom' -import { get, del, post, put } from '@/api/client' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { get, del, post, put, apiUrl } from '@/api/client' +import { SuperIndividualTab } from '@/pages/users/SuperIndividualTab' +import { ckbWorkbenchHref } from '@/lib/ckbWorkbench' interface User { id: string @@ -70,6 +69,10 @@ interface User { pendingEarnings?: number | string withdrawnEarnings?: number | string referralCount?: number + /** 推荐链接被打开次数(referral_visits) */ + referralClickCount?: number + /** 绑定/转化人数(bindings/订单/referral_count 取大,与 referralCount 一致) */ + referralBindCount?: number createdAt: string updatedAt?: string | null mbti?: string | null @@ -88,6 +91,7 @@ interface UserRule { trigger: string triggerConditions?: string[] actionType?: string + /** 小程序与运营约定字段,如 chapterThreshold、ctaKind、triggerDisplay */ actionConfig?: Recordsort: number enabled: boolean @@ -101,9 +105,12 @@ const TRIGGER_OPTIONS: { value: string; label: string; group: string }[] = [ { value: 'update_nickname', label: '修改昵称(非默认微信昵称,与头像分开)', group: '用户状态' }, { value: 'fill_profile', label: '完善资料(MBTI/行业/职位,不含头像昵称)', group: '用户状态' }, { value: 'view_chapter', label: '浏览章节', group: '阅读行为' }, - { value: 'browse_5_chapters', label: '累计浏览5个章节', group: '阅读行为' }, + /** 与小程序约定:distinct 章节数阈值见 actionConfig.chapterThreshold */ + { value: 'browse_n_chapters', label: '累计浏览 N 个章节(N 在「数值参数」)', group: '阅读行为' }, + { value: 'browse_5_chapters', label: '累计浏览 5 个章节(兼容旧数据,建议改为「N 章节」)', group: '阅读行为' }, + { value: 'view_n_chapters_pay365', label: '浏览 N 章后弹出 365 读书会支付', group: '阅读行为' }, { value: 'purchase_section', label: '购买单章', group: '付费行为' }, - { value: 'purchase_fullbook', label: '购买全书/VIP', group: '付费行为' }, + { value: 'purchase_fullbook', label: '365读书会/VIP', group: '付费行为' }, { value: 'after_pay', label: '任意付款成功', group: '付费行为' }, { value: 'after_match', label: '完成派对匹配', group: '社交行为' }, { value: 'click_super_individual', label: '点击超级个体头像', group: '社交行为' }, @@ -141,23 +148,49 @@ function normalizeTriggerConditions(v: unknown): string[] { return [] } -function normalizeUserRule(r: UserRule): UserRule { - return { ...r, triggerConditions: normalizeTriggerConditions(r.triggerConditions) } +/** 与后端 action_config JSON 对齐;小程序端可按 chapterThreshold / ctaKind 执行 */ +function normalizeActionConfig(v: unknown): Record { + let raw: Record = {} + if (v && typeof v === 'object' && !Array.isArray(v)) raw = { ...(v as Record ) } + else if (typeof v === 'string' && v.trim()) { + try { + const p = JSON.parse(v) as unknown + if (p && typeof p === 'object' && !Array.isArray(p)) raw = p as Record + } catch { + /* ignore */ + } + } + const chapterThreshold = Number(raw.chapterThreshold) + const ctaRaw = String(raw.ctaKind || 'default') + const ctaKind = ['default', 'share', 'pay365'].includes(ctaRaw) ? ctaRaw : 'default' + const rev = String(raw.revealMode || '') + const revealMode = rev === 'anchor' || rev === 'modal' ? rev : 'modal' + return { + ...raw, + chapterThreshold: Number.isFinite(chapterThreshold) && chapterThreshold > 0 ? chapterThreshold : 5, + ctaKind, + triggerDisplay: typeof raw.triggerDisplay === 'string' ? raw.triggerDisplay : '', + revealMode, + anchorLabel: typeof raw.anchorLabel === 'string' ? raw.anchorLabel : '', + } } -interface VipMember { - id: string - name: string - avatar?: string | null - mbti?: string | null - vipRole?: string | null - vipSort?: number | null - /** 首页超级个体卡片点击次数(/api/db/vip-members 聚合 user_tracks) */ - clickCount?: number | null - /** 绑定人物后的去重获客人数 */ - leadCount?: number | null +function normalizeUserRule(r: UserRule): UserRule { + return { + ...r, + triggerConditions: normalizeTriggerConditions(r.triggerConditions), + actionConfig: normalizeActionConfig(r.actionConfig), + } } +const DEFAULT_RULE_ACTION_CONFIG = (): Record => ({ + chapterThreshold: 5, + ctaKind: 'default', + triggerDisplay: '', + revealMode: 'modal', + anchorLabel: '', +}) + /** 与后端 admin_rfm.go calcRFMLevel 一致;列表 RFM 列即「Are you good」分层 */ const RFM_LEVEL_RULES: { level: string; range: string; label: string }[] = [ { level: 'S', range: '≥85', label: '高价值' }, @@ -167,17 +200,6 @@ const RFM_LEVEL_RULES: { level: string; range: string; label: string }[] = [ { level: 'D', range: '<30', label: '待激活' }, ] -// 用户旅程阶段定义 -const JOURNEY_STAGES = [ - { id: 'register', label: '注册/登录', icon: '👤', color: 'bg-blue-500/20 border-blue-500/40 text-blue-400', desc: '微信授权登录或手机号注册' }, - { id: 'browse', label: '浏览章节', icon: '📖', color: 'bg-purple-500/20 border-purple-500/40 text-purple-400', desc: '点击免费/付费章节预览' }, - { id: 'bind_phone', label: '绑定手机', icon: '📱', color: 'bg-cyan-500/20 border-cyan-500/40 text-cyan-400', desc: '触发付费章节后绑定手机' }, - { id: 'first_pay', label: '首次付款', icon: '💳', color: 'bg-green-500/20 border-green-500/40 text-green-400', desc: '购买单章或全书' }, - { id: 'fill_profile', label: '完善资料', icon: '✍️', color: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400', desc: '填写头像、MBTI、行业等' }, - { id: 'match', label: '派对房匹配', icon: '🤝', color: 'bg-orange-500/20 border-orange-500/40 text-orange-400', desc: '参与 Soul 派对房' }, - { id: 'vip', label: '升级 VIP', icon: '👑', color: 'bg-amber-500/20 border-amber-500/40 text-amber-400', desc: '付款 ¥1980 购买全书' }, - { id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' }, -] function confirmDangerousDelete(entity: string): boolean { if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false @@ -185,73 +207,14 @@ function confirmDangerousDelete(entity: string): boolean { return verifyText === '删除' } -/** 获客列表:头像 + 昵称,有 userId 时可点进用户详情 */ -function LeadUserNickCell({ - userId, - userAvatar, - nickname, - name, - onOpenDetail, -}: { - userId?: string - userAvatar?: string - nickname?: string - name?: string - onOpenDetail: (id: string) => void -}) { - const [imgFailed, setImgFailed] = useState(false) - const label = nickname || name || '-' - const initial = (label === '-' ? '?' : label).charAt(0) - const showImg = !!userAvatar?.trim() && !imgFailed - - return ( - -- ) -} - -function prettyJson(raw: string): string { - const s = (raw || '').trim() - if (!s) return '' - try { - return JSON.stringify(JSON.parse(s), null, 2) - } catch { - return s - } -} - export function UsersPage() { + const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null const rawTabParam = searchParams.get('tab') || 'users' - const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users' - const leadActionParam = (searchParams.get('leadAction') || '').trim() + const tabParam = ['users', 'rules', 'vip-roles'].includes(rawTabParam) + ? rawTabParam + : 'users' // ===== 用户列表 state ===== const [users, setUsers] = useState- {showImg ? ( --setImgFailed(true)} - /> - ) : ( - {initial} - )} -
{ - if (userId) onOpenDetail(userId) - }} - > - {label} - -([]) @@ -270,6 +233,11 @@ export function UsersPage() { const [rfmAlgoOpen, setRfmAlgoOpen] = useState(false) const [rfmSortDir, setRfmSortDir] = useState<'desc' | 'asc'>('desc') + // RFM 权重配置(可编辑) + const [rfmWeights, setRfmWeights] = useState({ recency: 0.25, frequency: 0.20, monetary: 0.20, referral: 0.15, track: 0.10, profile: 0.10 }) + const [rfmWeightsLoading, setRfmWeightsLoading] = useState(false) + const [rfmWeightsSaving, setRfmWeightsSaving] = useState(false) + useEffect(() => { if (poolParam === 'vip') setVipFilter('vip') else if (poolParam === 'complete') setVipFilter('complete') @@ -294,263 +262,20 @@ export function UsersPage() { const [rulesLoading, setRulesLoading] = useState(false) const [showRuleModal, setShowRuleModal] = useState(false) const [editingRule, setEditingRule] = useState (null) - const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', triggerConditions: [] as string[], actionType: 'popup', sort: 0, enabled: true }) + const [ruleToDelete, setRuleToDelete] = useState (null) + const [ruleForm, setRuleForm] = useState({ + title: '', + description: '', + trigger: '', + triggerConditions: [] as string[], + actionType: 'popup', + actionConfig: DEFAULT_RULE_ACTION_CONFIG(), + sort: 0, + enabled: true, + }) - // ===== 超级个体(VIP 用户列表) ===== - const [vipMembers, setVipMembers] = useState ([]) - const [vipMembersLoading, setVipMembersLoading] = useState(false) - const [draggingVipId, setDraggingVipId] = useState (null) - const [dragOverVipId, setDragOverVipId] = useState (null) - - // ===== 用户旅程总览 ===== - const [journeyStats, setJourneyStats] = useState >({}) - const [journeyLoading, setJourneyLoading] = useState(false) - const [journeyStage, setJourneyStage] = useState (null) - const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([]) - const [journeyUsersLoading, setJourneyUsersLoading] = useState(false) - const [trackUserId, setTrackUserId] = useState (null) - const [trackUserNick, setTrackUserNick] = useState('') - const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([]) - const [userTracksLoading, setUserTracksLoading] = useState(false) const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState >({}) - // ===== 获客列表(存客宝) ===== - const [leadsRecords, setLeadsRecords] = useState<{ - id: number - userId?: string - userNickname?: string - /** 与会员资料一致,来自 users.avatar(接口已 resolve) */ - userAvatar?: string - phone?: string - wechatId?: string - name?: string - source?: string - planApiKey?: string - personName?: string - pushStatus?: 'pending' | 'success' | 'failed' | string - retryCount?: number - ckbCode?: number - ckbMessage?: string - ckbData?: string - ckbError?: string - lastPushAt?: string - nextRetryAt?: string - createdAt?: string - }[]>([]) - const [leadsTotal, setLeadsTotal] = useState(0) - const [leadsPage, setLeadsPage] = useState(1) - const [leadsPageSize] = useState(10) - const [leadsLoading, setLeadsLoading] = useState(false) - const [leadsError, setLeadsError] = useState (null) - const [leadsSearchTerm, setLeadsSearchTerm] = useState('') - const debouncedLeadsSearch = useDebounce(leadsSearchTerm, 300) - const [leadsSourceFilter, setLeadsSourceFilter] = useState('') - const [leadsActionFilter, setLeadsActionFilter] = useState('') - const [leadsPushStatusFilter, setLeadsPushStatusFilter] = useState('') - const [leadsDedupEnabled, setLeadsDedupEnabled] = useState(false) - const [leadsStats, setLeadsStats] = useState<{ uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }>({}) - const [retryingLeadId, setRetryingLeadId] = useState (null) - const [deletingLeadId, setDeletingLeadId] = useState (null) - const [leadSelectedIds, setLeadSelectedIds] = useState ([]) - const [batchDeletingLeads, setBatchDeletingLeads] = useState(false) - const leadsHeaderCheckboxRef = useRef (null) - const [batchRetrying, setBatchRetrying] = useState(false) - const [showCkbDataDialog, setShowCkbDataDialog] = useState(false) - const [ckbDataDialogTitle, setCkbDataDialogTitle] = useState('存客宝返回 data') - const [ckbDataDialogContent, setCkbDataDialogContent] = useState('') - const loadLeads = useCallback(async (searchVal?: string, sourceVal?: string) => { - setLeadsLoading(true) - setLeadsError(null) - try { - const params = new URLSearchParams({ - mode: 'contact', - page: String(leadsPage), - pageSize: String(leadsPageSize), - }) - const s = searchVal ?? debouncedLeadsSearch - if (s) params.set('search', s) - const src = sourceVal ?? leadsSourceFilter - if (src) params.set('source', src) - if (leadsActionFilter) params.set('action', leadsActionFilter) - if (leadsPushStatusFilter) params.set('pushStatus', leadsPushStatusFilter) - const data = await get<{ - success?: boolean; records?: unknown[]; total?: number; - stats?: { uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] } - error?: string - }>(`/api/db/ckb-leads?${params}`) - if (data?.success) { - setLeadsRecords((data.records || []) as typeof leadsRecords) - setLeadsTotal(data.total ?? 0) - if (data.stats) setLeadsStats(data.stats) - } else { - const msg = data?.error || '加载获客列表失败' - setLeadsError(msg) - toast.error(msg) - setLeadsRecords([]) - setLeadsTotal(0) - } - } catch (e) { - const msg = e instanceof Error ? e.message : '网络错误' - setLeadsError(msg) - toast.error('加载获客列表失败: ' + msg) - setLeadsRecords([]) - setLeadsTotal(0) - } finally { - setLeadsLoading(false) - } - }, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter]) - - useEffect(() => { - setLeadSelectedIds([]) - }, [debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter]) - - // URL 同步:只在获客列表 Tab 时读取 leadAction,默认空=全部类型 - useEffect(() => { - if (tabParam !== 'leads') return - setLeadsActionFilter(leadActionParam) - }, [tabParam, leadActionParam]) - - type LeadRetryRecordPatch = { - pushStatus?: string - retryCount?: number - ckbCode?: number - ckbMessage?: string - ckbData?: string - ckbError?: string - lastPushAt?: string | null - nextRetryAt?: string | null - } - - function mergeLeadRowAfterRetry(row: (typeof leadsRecords)[0], rec: LeadRetryRecordPatch) { - return { - ...row, - ...(rec.pushStatus !== undefined ? { pushStatus: rec.pushStatus } : {}), - ...(typeof rec.retryCount === 'number' ? { retryCount: rec.retryCount } : {}), - ...(typeof rec.ckbCode === 'number' ? { ckbCode: rec.ckbCode } : {}), - ...(rec.ckbMessage !== undefined ? { ckbMessage: rec.ckbMessage } : {}), - ...(rec.ckbData !== undefined ? { ckbData: rec.ckbData } : {}), - ...(rec.ckbError !== undefined ? { ckbError: rec.ckbError } : {}), - ...(rec.lastPushAt !== undefined ? { lastPushAt: rec.lastPushAt ?? undefined } : {}), - ...(rec.nextRetryAt !== undefined ? { nextRetryAt: rec.nextRetryAt ?? undefined } : {}), - } - } - - async function retryLeadPush(recordId: number) { - if (!recordId) return - setRetryingLeadId(recordId) - try { - const data = await post<{ - success?: boolean - pushed?: boolean - error?: string - record?: LeadRetryRecordPatch - }>('/api/db/ckb-leads/retry', { id: recordId }) - if (data?.success) { - toast.success(data.pushed ? '重推成功' : '已发起重推,请刷新查看状态') - if (data.record) { - setLeadsRecords((prev) => - prev.map((row) => - row.id === recordId ? mergeLeadRowAfterRetry(row, data.record!) : row, - ), - ) - } - } else { - toast.error(data?.error || '重推失败') - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '重推请求失败') - } finally { - setRetryingLeadId(null) - } - } - - async function deleteLeadRecord(recordId: number) { - if (!recordId) return - if (!confirm('确定删除该条获客记录?删除后不可恢复。')) return - setDeletingLeadId(recordId) - try { - const data = await post<{ success?: boolean; error?: string }>('/api/db/ckb-leads/delete', { id: recordId }) - if (data?.success) { - toast.success('已删除') - } else { - toast.error(data?.error || '删除失败') - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '删除请求失败') - } finally { - setDeletingLeadId(null) - loadLeads() - } - } - - async function retryFailedLeadsInPage() { - const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') - if (failedRows.length === 0) { - toast.info('当前页无失败记录') - return - } - setBatchRetrying(true) - let successCount = 0 - for (const row of failedRows) { - try { - const data = await post<{ - success?: boolean - pushed?: boolean - record?: LeadRetryRecordPatch - }>('/api/db/ckb-leads/retry', { id: row.id }) - if (data?.success && data.pushed) successCount++ - if (data?.success && data.record) { - const patch = data.record - setLeadsRecords((prev) => - prev.map((r) => (r.id === row.id ? mergeLeadRowAfterRetry(r, patch) : r)), - ) - } - } catch { - // 单条失败继续下一条,避免中断整批重推 - } - } - setBatchRetrying(false) - const total = failedRows.length - toast.success(`批量重推完成:成功 ${successCount} / ${total}`) - } - - function exportFailedLeadsCsv() { - const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') - if (failedRows.length === 0) { - toast.info('当前筛选下无失败记录可导出') - return - } - const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"` - const headers = ['ID', '昵称', '手机号', '微信号', '对应@人', '计划Key', '来源', '推送状态', '重试次数', '失败原因', '下次重试时间', '创建时间'] - const lines = [headers.join(',')] - for (const r of failedRows) { - lines.push([ - esc(r.id), - esc(r.userNickname || r.name || ''), - esc(r.phone || ''), - esc(r.wechatId || ''), - esc(r.personName || ''), - esc(r.planApiKey || ''), - esc(r.source || ''), - esc(r.pushStatus || ''), - esc(typeof r.retryCount === 'number' ? r.retryCount : ''), - esc(r.ckbError || ''), - esc(r.nextRetryAt ? new Date(r.nextRetryAt).toLocaleString() : ''), - esc(r.createdAt ? new Date(r.createdAt).toLocaleString() : ''), - ].join(',')) - } - const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `获客失败清单-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - toast.success(`已导出失败清单(${failedRows.length} 条)`) - } - const loadMbtiAvatarsMap = useCallback(async () => { try { const data = await get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars') @@ -560,17 +285,6 @@ export function UsersPage() { setMbtiAvatarsMap({}) } }, []) - useEffect(() => { - if (searchParams.get('tab') === 'leads') loadLeads() - }, [searchParams.get('tab'), leadsPage, loadLeads]) - - useEffect(() => { - if (tabParam !== 'leads') return - const timer = window.setInterval(() => { - loadLeads() - }, 30000) - return () => window.clearInterval(timer) - }, [tabParam, loadLeads]) useEffect(() => { loadMbtiAvatarsMap() @@ -578,10 +292,16 @@ export function UsersPage() { const resolveUserAvatarByMbti = useCallback((avatar: string | null | undefined, mbti: string | null | undefined): string => { const av = (avatar || '').trim() - if (av) return av + if (av) { + if (av.startsWith('http://') || av.startsWith('https://') || av.startsWith('data:')) return av + return apiUrl(av.startsWith('/') ? av : `/${av}`) + } const key = (mbti || '').trim().toUpperCase() if (!/^[EI][NS][FT][JP]$/.test(key)) return '' - return (mbtiAvatarsMap[key] || '').trim() + const mbtiUrl = (mbtiAvatarsMap[key] || '').trim() + if (!mbtiUrl) return '' + if (mbtiUrl.startsWith('http://') || mbtiUrl.startsWith('https://') || mbtiUrl.startsWith('data:')) return mbtiUrl + return apiUrl(mbtiUrl.startsWith('/') ? mbtiUrl : `/${mbtiUrl}`) }, [mbtiAvatarsMap]) const getPurchaseState = useCallback((user: User) => { @@ -625,6 +345,27 @@ export function UsersPage() { return () => clearInterval(t) }, [loadOnlineStats]) + const loadRfmWeights = useCallback(async () => { + setRfmWeightsLoading(true) + try { + const data = await get<{ success?: boolean; weights?: typeof rfmWeights }>('/api/admin/rfm-weights') + if (data?.success && data.weights) setRfmWeights(data.weights) + } catch { /* use defaults */ } + finally { setRfmWeightsLoading(false) } + }, []) + + useEffect(() => { loadRfmWeights() }, [loadRfmWeights]) + + async function handleSaveRfmWeights() { + setRfmWeightsSaving(true) + try { + const data = await put<{ success?: boolean; error?: string }>('/api/admin/rfm-weights', rfmWeights) + if (data?.success) toast.success('RFM 权重已保存') + else toast.error(data?.error || '保存失败') + } catch { toast.error('保存权重失败') } + finally { setRfmWeightsSaving(false) } + } + // ===== 用户列表 ===== async function loadUsers(fromRefresh = false) { setIsLoading(true) @@ -764,228 +505,122 @@ export function UsersPage() { try { const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules') if (data?.success) setRules((data.rules || []).map((r) => normalizeUserRule(r))) - } catch { } finally { setRulesLoading(false) } + else toast.error((data as { error?: string } | undefined)?.error || '加载规则失败') + } catch { + toast.error('规则列表加载失败') + } finally { + setRulesLoading(false) + } }, []) + /** 后端 PUT 会对未传字段写空字符串,开关必须带齐字段 */ + function buildRulePutPayload(rule: UserRule, overrides: Partial<{ enabled: boolean }>) { + const tc = normalizeTriggerConditions(rule.triggerConditions) + const ac = normalizeActionConfig(rule.actionConfig) + return { + id: rule.id, + title: rule.title, + description: rule.description, + trigger: rule.trigger, + triggerConditions: tc, + actionType: rule.actionType || 'popup', + actionConfig: ac, + sort: rule.sort, + enabled: overrides.enabled !== undefined ? overrides.enabled : rule.enabled, + } + } + async function handleSaveRule() { - if (!ruleForm.title) { toast.error('请填写规则标题'); return } + if (!ruleForm.title.trim()) { toast.error('请填写规则标题'); return } + const chapterThreshold = Math.max(1, Math.floor(Number(ruleForm.actionConfig.chapterThreshold) || 5)) + const ctaRaw = String(ruleForm.actionConfig.ctaKind || 'default') + const ctaKind = ['default', 'share', 'pay365'].includes(ctaRaw) ? ctaRaw : 'default' + const revRaw = String(ruleForm.actionConfig.revealMode || '') + const revealMode = revRaw === 'anchor' || revRaw === 'modal' ? revRaw : 'modal' + const actionConfigOut = { + ...ruleForm.actionConfig, + chapterThreshold, + ctaKind, + triggerDisplay: typeof ruleForm.actionConfig.triggerDisplay === 'string' ? ruleForm.actionConfig.triggerDisplay.trim() : '', + revealMode, + anchorLabel: typeof ruleForm.actionConfig.anchorLabel === 'string' ? ruleForm.actionConfig.anchorLabel.trim() : '', + } setIsSaving(true) try { if (editingRule) { - const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { id: editingRule.id, ...ruleForm }) + const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { + id: editingRule.id, + title: ruleForm.title.trim(), + description: ruleForm.description, + trigger: ruleForm.trigger.trim(), + triggerConditions: ruleForm.triggerConditions, + actionType: ruleForm.actionType, + actionConfig: actionConfigOut, + sort: ruleForm.sort, + enabled: ruleForm.enabled, + }) if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return } + toast.success('规则已更新') } else { - const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm) + const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', { + title: ruleForm.title.trim(), + description: ruleForm.description, + trigger: ruleForm.trigger.trim(), + triggerConditions: ruleForm.triggerConditions, + actionType: ruleForm.actionType, + actionConfig: actionConfigOut, + sort: ruleForm.sort, + enabled: ruleForm.enabled, + }) if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return } + toast.success('规则已创建') } - setShowRuleModal(false); loadRules() - } catch { toast.error('保存失败') } finally { setIsSaving(false) } + setShowRuleModal(false) + loadRules() + } catch { + toast.error('保存失败') + } finally { + setIsSaving(false) + } } - async function handleDeleteRule(id: number) { - if (!confirmDangerousDelete('规则')) { - toast.info('已取消删除') - return - } + async function confirmDeleteRule() { + if (!ruleToDelete) return try { - const data = await del<{ success?: boolean }>(`/api/db/user-rules?id=${id}`) - if (data?.success) loadRules() - } catch { } + const data = await del<{ success?: boolean; error?: string }>(`/api/db/user-rules?id=${ruleToDelete.id}`) + if (data?.success) { + toast.success('已删除规则') + loadRules() + setRuleToDelete(null) + } else { + toast.error(data?.error || '删除失败') + } + } catch { + toast.error('删除失败') + } } async function handleToggleRule(rule: UserRule) { - try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { } - } - - // ===== 超级个体(VIP 用户列表) ===== - const loadVipMembers = useCallback(async () => { - setVipMembersLoading(true) try { - const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>( - '/api/db/vip-members?limit=500', + const next = !rule.enabled + const data = await put<{ success?: boolean; error?: string }>( + '/api/db/user-rules', + buildRulePutPayload(rule, { enabled: next }), ) - if (data?.success && data.data) { - const list = [...data.data].map((m, idx) => ({ - ...m, - vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1, - })) - list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999)) - setVipMembers(list) - } else if (data && data.error) { - toast.error(data.error) + if (data?.success) { + toast.success(next ? '已开启规则' : '已关闭规则') + loadRules() + } else { + toast.error(data?.error || '切换失败') } } catch { - toast.error('加载超级个体列表失败') - } finally { - setVipMembersLoading(false) - } - }, []) - - const [showVipRoleModal, setShowVipRoleModal] = useState(false) - const [vipRoleModalMember, setVipRoleModalMember] = useState (null) - const [vipRoleInput, setVipRoleInput] = useState('') - const [vipRoleSaving, setVipRoleSaving] = useState(false) - const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手'] - - const openVipRoleModal = (member: VipMember) => { - setVipRoleModalMember(member) - setVipRoleInput(member.vipRole || '') - setShowVipRoleModal(true) - } - - const handleSetVipRole = async (value: string) => { - const trimmed = value.trim() - if (!vipRoleModalMember) return - if (!trimmed) { - toast.error('请选择或输入标签') - return - } - setVipRoleSaving(true) - try { - const res = await put<{ success?: boolean; error?: string }>('/api/db/users', { - id: vipRoleModalMember.id, - vipRole: trimmed, - }) - if (!res?.success) { - toast.error(res?.error || '更新超级个体标签失败') - return - } - toast.success('已更新超级个体标签') - setShowVipRoleModal(false) - setVipRoleModalMember(null) - await loadVipMembers() - } catch { - toast.error('更新超级个体标签失败') - } finally { - setVipRoleSaving(false) + toast.error('切换失败') } } - const [showVipSortModal, setShowVipSortModal] = useState(false) - const [vipSortModalMember, setVipSortModalMember] = useState (null) - const [vipSortInput, setVipSortInput] = useState('') - const [vipSortSaving, setVipSortSaving] = useState(false) - - const openVipSortModal = (member: VipMember) => { - setVipSortModalMember(member) - setVipSortInput(member.vipSort != null ? String(member.vipSort) : '') - setShowVipSortModal(true) - } - - const handleSetVipSort = async () => { - if (!vipSortModalMember) return - const num = Number(vipSortInput) - if (!Number.isFinite(num)) { - toast.error('请输入有效的数字序号') - return - } - setVipSortSaving(true) - try { - const res = await put<{ success?: boolean; error?: string }>('/api/db/users', { - id: vipSortModalMember.id, - vipSort: num, - }) - if (!res?.success) { - toast.error(res?.error || '更新排序序号失败') - return - } - toast.success('已更新排序序号') - setShowVipSortModal(false) - setVipSortModalMember(null) - await loadVipMembers() - } catch { - toast.error('更新排序序号失败') - } finally { - setVipSortSaving(false) - } - } - - const handleVipRowDragStart = (e: React.DragEvent , id: string) => { - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', id) - setDraggingVipId(id) - } - - const handleVipRowDragOver = (e: React.DragEvent , id: string) => { - e.preventDefault() - if (dragOverVipId !== id) setDragOverVipId(id) - } - - const handleVipRowDragEnd = () => { - setDraggingVipId(null) - setDragOverVipId(null) - } - - const handleVipRowDrop = async (e: React.DragEvent , targetId: string) => { - e.preventDefault() - const fromId = e.dataTransfer.getData('text/plain') || draggingVipId - setDraggingVipId(null) - setDragOverVipId(null) - if (!fromId || fromId === targetId) return - - const fromMember = vipMembers.find((m) => m.id === fromId) - const targetMember = vipMembers.find((m) => m.id === targetId) - if (!fromMember || !targetMember) return - - const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1 - const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1 - - // 本地先交换顺序,提升交互流畅度 - setVipMembers((prev) => { - const list = [...prev] - const fromIdx = list.findIndex((m) => m.id === fromId) - const toIdx = list.findIndex((m) => m.id === targetId) - if (fromIdx === -1 || toIdx === -1) return prev - const next = [...list] - const [m1, m2] = [next[fromIdx], next[toIdx]] - next[fromIdx] = { ...m2, vipSort: fromSort } - next[toIdx] = { ...m1, vipSort: targetSort } - return next - }) - - try { - const [res1, res2] = await Promise.all([ - put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }), - put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }), - ]) - if (!res1?.success || !res2?.success) { - toast.error(res1?.error || res2?.error || '更新排序失败') - await loadVipMembers() - return - } - toast.success('已更新排序') - await loadVipMembers() - } catch { - toast.error('更新排序失败') - await loadVipMembers() - } - } - - // ===== 用户旅程总览 ===== - const loadJourneyStats = useCallback(async () => { - setJourneyLoading(true) - try { - const data = await get<{ success?: boolean; stats?: Record }>('/api/db/users/journey-stats') - if (data?.success && data.stats) setJourneyStats(data.stats) - } catch { } finally { setJourneyLoading(false) } - }, []) - const loadJourneyUsers = useCallback(async (stage: string) => { - setJourneyStage(stage) - setJourneyUsersLoading(true) - try { - const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`) - if (data?.success && data.users) setJourneyUsers(data.users) - } catch { } finally { setJourneyUsersLoading(false) } - }, []) - const loadUserTracks = useCallback(async (userId: string, nick: string) => { - setTrackUserId(userId) - setTrackUserNick(nick) - setUserTracksLoading(true) - try { - const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`) - if (data?.success && data.tracks) setUserTracks(data.tracks) - } catch { } finally { setUserTracksLoading(false) } - }, []) + useEffect(() => { + if (tabParam === 'rules') loadRules() + }, [tabParam, loadRules]) // ===== 批量用户补全 ===== const [batchEnrichLoading, setBatchEnrichLoading] = useState(false) @@ -1015,164 +650,6 @@ export function UsersPage() { return Math.round((filled / fields.length) * 100) } - /** 展示列表:默认不去重(按原始记录展示);可选开启去重用于运营查看「按人合并」 */ - const { leadsRows, leadsRawCount, leadsDeduped } = useMemo(() => { - const normalizePhone = (p?: string | null) => (p || '').replace(/\D/g, '') || '' - const dedupKey = (r: (typeof leadsRecords)[0]) => { - const phone = normalizePhone(r.phone) - if (phone) return `phone:${phone}` - const uid = (r.userId || '').trim() - if (uid) return `user:${uid}` - const wx = (r.wechatId || '').trim() - if (wx) return `wechat:${wx}` - return `row:${r.id}` - } - const q = debouncedLeadsSearch.trim().toLowerCase() - let rows = leadsRecords - if (q) { - rows = leadsRecords.filter((r) => { - const blob = [r.userNickname, r.name, r.phone, r.wechatId, r.personName, r.source, r.planApiKey] - .filter(Boolean) - .join(' ') - .toLowerCase() - return blob.includes(q) - }) - } - const sorted = [...rows].sort((a, b) => { - const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return tb - ta - }) - if (!leadsDedupEnabled) { - return { leadsRows: sorted, leadsRawCount: rows.length, leadsDeduped: 0 } - } - const seen = new Set () - const out: typeof leadsRecords = [] - for (const r of sorted) { - const k = dedupKey(r) - if (seen.has(k)) continue - seen.add(k) - out.push(r) - } - return { leadsRows: out, leadsRawCount: rows.length, leadsDeduped: rows.length - out.length } - }, [leadsRecords, debouncedLeadsSearch, leadsDedupEnabled]) - - useEffect(() => { - const pageIds = leadsRows.map((r) => r.id) - const n = pageIds.filter((id) => leadSelectedIds.includes(id)).length - const el = leadsHeaderCheckboxRef.current - if (el) { - el.indeterminate = n > 0 && n < pageIds.length - } - }, [leadsRows, leadSelectedIds]) - - function toggleLeadSelectAllOnPage() { - const pageIds = leadsRows.map((r) => r.id) - const allOn = pageIds.length > 0 && pageIds.every((id) => leadSelectedIds.includes(id)) - if (allOn) { - setLeadSelectedIds((prev) => prev.filter((id) => !pageIds.includes(id))) - } else { - setLeadSelectedIds((prev) => [...new Set([...prev, ...pageIds])]) - } - } - - function toggleLeadOne(id: number) { - setLeadSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) - } - - async function batchDeleteLeadRecords() { - if (leadSelectedIds.length === 0) { - toast.info('请先勾选要删除的记录') - return - } - const n = leadSelectedIds.length - if (!confirm(`确定批量删除选中的 ${n} 条获客记录?删除后不可恢复。`)) return - const CHUNK = 500 - setBatchDeletingLeads(true) - try { - let totalDeleted = 0 - for (let i = 0; i < leadSelectedIds.length; i += CHUNK) { - const slice = leadSelectedIds.slice(i, i + CHUNK) - const data = await post<{ success?: boolean; deleted?: number; error?: string }>( - '/api/db/ckb-leads/delete-batch', - { ids: slice }, - ) - if (!data?.success) { - toast.error(data?.error || '批量删除失败') - return - } - totalDeleted += Number(data.deleted) || 0 - } - toast.success(`已删除 ${totalDeleted} 条`) - setLeadSelectedIds([]) - } catch (e) { - toast.error(e instanceof Error ? e.message : '批量删除请求失败') - } finally { - setBatchDeletingLeads(false) - loadLeads() - } - } - - const pushStatusBadge = (status?: string) => { - const s = status || '' - if (s === 'success') - return 已推送(存客宝已接收) - if (s === 'pending_verify') - return待通过 / 处理中 - if (s === 'expired') return已过期 - if (s === 'failed') return失败 - if (s === 'pending') - return待推送 - if (s) - return ( -- {s} - - ) - return待推送 - } - - const failedReasonStats = useMemo(() => { - const map = new Map() - for (const r of leadsRows) { - if (r.pushStatus !== 'failed') continue - const reason = (r.ckbError || '未知错误').trim() || '未知错误' - map.set(reason, (map.get(reason) || 0) + 1) - } - return Array.from(map.entries()) - .map(([reason, count]) => ({ reason, count })) - .sort((a, b) => b.count - a.count) - }, [leadsRows]) - - async function copyFailedDebugInfo() { - const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed') - if (failedRows.length === 0) { - toast.info('当前页无失败记录') - return - } - const topReasons = failedReasonStats.slice(0, 8).map((x) => `- ${x.reason}:${x.count} 条`).join('\n') - const recentIds = failedRows.slice(0, 30).map((r) => r.id).join(', ') - const detailRows = failedRows.slice(0, 20).map((r) => ( - `#${r.id} | ${r.userNickname || r.name || '-'} | 手机:${r.phone || '-'} | 来源:${r.source || '-'} | 重试:${r.retryCount ?? 0} | 错误:${r.ckbError || '-'}` - )).join('\n') - const text = [ - `【获客失败排障信息】`, - `时间:${new Date().toLocaleString()}`, - `当前页失败总数:${failedRows.length}`, - `主要失败原因:`, - topReasons || '- 无', - `最近失败记录ID(最多30条):${recentIds || '无'}`, - '', - `失败记录明细(最多20条):`, - detailRows || '无', - ].join('\n') - try { - await navigator.clipboard.writeText(text) - toast.success('已复制排障信息') - } catch { - toast.error('复制失败,请检查浏览器剪贴板权限') - } - } return ( @@ -1183,90 +660,109 @@ export function UsersPage() {)} -+--用户管理
共 {total} 位注册用户 {onlineCount !== null && · 在线 {onlineCount} 人} - {rfmSortMode && ' · RFM 排序中'}
- - -+ {rfmAlgoOpen && ( +setRfmAlgoOpen((v) => !v)} - className="flex items-center gap-2 min-w-0 flex-1 text-left rounded-lg px-1 py-0.5 hover:bg-white/5 transition-colors" - aria-expanded={rfmAlgoOpen} - > - +- -- {rfmAlgoOpen ? ( -算法配置-- {rfmAlgoOpen ? 'RFM · Are you good(用户价值分层)' : 'RFM · 点击展开说明'} --- ) : ( - - )} - ++navigate(ckbWorkbenchHref())} + className="border-orange-500/45 text-orange-400 hover:bg-orange-500/10 bg-transparent" + title="存客宝:线索、接口文档、用户推送说明" + > + ++ 存客宝 + setRfmAlgoOpen((v) => !v)} + className="border-[#38bdac]/50 text-[#38bdac] hover:bg-[#38bdac]/10 bg-transparent" + > + ++ 算法配置 + {rfmAlgoOpen ? : } + + + +- {rfmAlgoOpen && ( - <> -RFM 权重配置
- {rfmSortMode - ? rfmSortDir === 'desc' - ? 'RFM 降序' - : 'RFM 升序' - : '按 RFM 排序'} + + {rfmWeightsSaving ? '保存中…' : '保存权重'} - 综合分 0–100(六维度):最近消费 R(25%)+ 订单频次 F(20%)+ 累计金额 M(20%)+ 推荐人数(15%)+ 行为轨迹(10%)+ 资料完善(10%)。各维度在全量用户中归一化,与后端{' '} -
-/api/db/users/rfm一致。 -- {RFM_LEVEL_RULES.map(({ level, range, label }) => ( -+ )}- {level} {range} · {label} - - ))} ++ 综合分 0–100,各维度在全量用户中归一化后按权重加权。修改后点击「保存权重」生效。 +
++ {([ + { key: 'recency' as const, label: '最近消费 R' }, + { key: 'frequency' as const, label: '订单频次 F' }, + { key: 'monetary' as const, label: '累计金额 M' }, + { key: 'referral' as const, label: '推荐人数' }, + { key: 'track' as const, label: '行为轨迹' }, + { key: 'profile' as const, label: '资料完善' }, + ]).map(({ key, label }) => ( +++- > - )} + ))} +{label} + setRfmWeights((w) => ({ ...w, [key]: parseFloat(e.target.value) || 0 }))} + />+-+ 当前权重总和: a + b, 0) - 1) < 0.01 ? 'text-green-400' : 'text-yellow-400'}`}> + {Object.values(rfmWeights).reduce((a, b) => a + b, 0).toFixed(2)} + + {Math.abs(Object.values(rfmWeights).reduce((a, b) => a + b, 0) - 1) >= 0.01 && (建议总和为 1.00)} +
++ {RFM_LEVEL_RULES.map(({ level, range, label }) => ( +++ {level} {range} · {label} + + ))} +{ const sp = new URLSearchParams(searchParams); if (v === 'users') sp.delete('tab'); else sp.set('tab', v); setSearchParams(sp) }} className="w-full"> @@ -1285,11 +781,15 @@ export function UsersPage() { - 用户列表 loadLeads()}> - -获客列表 - - 用户旅程总览 - - 规则配置 - 超级个体列表 + + 超级个体 loadUsers(true)} + onClick={() => { + void loadOnlineStats() + loadUsers(true) + }} disabled={isRefreshLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent" + title="刷新用户列表并同步在线人数" > - 刷新 + 刷新列表与在线 - 用户信息 -绑定信息 -购买状态 -分销收益 +用户 +购买 +分销 -@@ -1402,19 +901,14 @@ export function UsersPage() { {user.isAdmin && 管理员 } {user.openId && !user.id?.startsWith('user_') &&微信 }- {user.id?.slice(0, 16)}{(user.id?.length ?? 0) > 16 ? '…' : ''} -
++ {user.phone && 📱 {user.phone}} + {user.wechatId && 💬 {user.wechatId}} + {!user.phone && !user.wechatId && 未绑定} +- - {user.phone &&-📱{user.phone}} - {user.wechatId &&💬{user.wechatId}} - {!user.phone && !user.wechatId && 未绑定} -{(() => { const purchase = getPurchaseState(user) @@ -1446,13 +940,25 @@ export function UsersPage() { })()} - -¥{parseFloat(String(user.earnings || 0)).toFixed(2)}- {parseFloat(String(user.pendingEarnings || 0)) > 0 && ( -待提现: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)}- )} -handleViewReferrals(user)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)}> -绑定{user.referralCount || 0}人 + +@@ -1498,7 +1004,7 @@ export function UsersPage() { ))} {users.length === 0 && (++¥{parseFloat(String(user.earnings || 0)).toFixed(2)}+ {parseFloat(String(user.pendingEarnings || 0)) > 0 && ( +待提现: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)}+ )} +handleViewReferrals(user)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)} + title="点击=带推荐码打开小程序次数;绑定=已绑定关系或带推荐人付费用户数" + > + +点击 {user.referralClickCount ?? 0} + + 绑定 {user.referralBindCount ?? user.referralCount ?? 0} - )} @@ -1510,590 +1016,6 @@ export function UsersPage() { - {/* ===== 获客列表(存客宝) ===== */} -暂无用户数据 +暂无用户数据 - {leadsError && ( - - - {/* ===== 用户旅程总览 ===== */} -- {leadsError} -- )} - {/* 统计摘要:无数据时也展示 0,避免空白 */} - {!leadsLoading && ( -setLeadsError(null)} aria-label="关闭">× --- )} - {!leadsLoading && failedReasonStats.length > 0 && ( ---线索总条数(含留资/加入/匹配)
-{leadsTotal}
--- {(leadsStats.sourceStats && leadsStats.sourceStats.length > 0 - ? leadsStats.sourceStats.slice(0, 2) - : [] - ).map((s) => ( -去重用户数(按 userId)
-{leadsStats.uniqueUsers ?? 0}
--- ))} -来源:{s.source}
-{s.cnt}
-- - )} -- -----失败原因聚合
-基于当前页筛选结果,按失败原因聚合统计
-- 一键复制排障信息 - -- {failedReasonStats.slice(0, 8).map((s) => ( --- {s.reason} · {s.count} - - ))} --- {!leadsLoading && leadsRows.length > 0 && ( ----- {leadsStats.sourceStats && leadsStats.sourceStats.length > 0 && ( -- setLeadsSearchTerm(e.target.value)} - className="pl-9 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500" - /> - { setLeadsSourceFilter(e.target.value); setLeadsPage(1) }} - className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm" - > - - {leadsStats.sourceStats.map((s) => ( - - ))} - - )} -{ - const v = e.target.value - setLeadsActionFilter(v) - setLeadsPage(1) - setSearchParams((prev) => { - const p = new URLSearchParams(prev) - p.set('tab', 'leads') - if (v) p.set('leadAction', v) - else p.delete('leadAction') - return p - }) - }} - className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm" - title="按业务类型筛选" - > - - - - - -- setLeadsDedupEnabled(e.target.checked)} - className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer" - /> - 去重展示 - -{ setLeadsPushStatusFilter(e.target.value); setLeadsPage(1) }} - className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm" - > - - - - - - - -{ setLeadsPushStatusFilter('failed'); setLeadsPage(1) }} - className="border-red-600/60 text-red-300 hover:bg-red-500/10 bg-transparent text-xs h-9" - > - 只看失败 - - - 本页 {leadsRawCount} 条{leadsDeduped > 0 ? ` · 已合并 ${leadsDeduped} 条重复` : ''} - ---- 导出失败清单 - -- -- 重推本页失败项 - void batchDeleteLeadRecords()} - disabled={batchDeletingLeads || leadSelectedIds.length === 0 || leadsLoading} - className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent" - > - -- {batchDeletingLeads ? '删除中…' : `批量删除(${leadSelectedIds.length})`} - loadLeads()} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"> - -刷新 - - 已选 {leadSelectedIds.length} 条 · 可翻页继续勾选 · 改搜索/筛选会清空选择 - {leadSelectedIds.length > 0 && ( -
- )} -setLeadSelectedIds([])} - > - 清空 - - )} -- -- {leadsLoading ? ( - --- ) : ( -- 加载中... - -- )} --
-- -- -- 0 && leadsRows.every((r) => leadSelectedIds.includes(r.id)) - } - onChange={toggleLeadSelectAllOnPage} - className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer" - title="全选本页(展示行)" - /> - -昵称 -手机号 -微信号 -对应 @人 -获客计划(Key) -推送状态 -时间 -重试 -- {leadsRows.map((r) => ( - -- - ))} - {leadsRows.length === 0 && ( -- toggleLeadOne(r.id)} - disabled={batchDeletingLeads} - className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer" - /> - -- -{ - setSelectedUserIdForDetail(id) - setShowDetailModal(true) - }} - /> - {r.phone || '-'} -{r.wechatId || '-'} -{r.personName || '-'} -- {(() => { - const k = (r.planApiKey || '').trim() - if (!k) return '-' - const masked = k.length <= 10 ? k : `${k.slice(0, 6)}…${k.slice(-4)}` - return ( - -{ - try { - await navigator.clipboard.writeText(k) - toast.success('已复制计划Key') - } catch { - toast.error('复制失败,请手动复制') - } - }} - > - {masked} - - ) - })()} -- -- {pushStatusBadge(r.pushStatus)} - {(typeof r.ckbCode === 'number' || (r.ckbMessage || '').trim()) && ( --- {[ - typeof r.ckbCode === 'number' ? `code=${r.ckbCode}` : '', - (r.ckbMessage || '').trim() ? String(r.ckbMessage).trim() : '', - ] - .filter(Boolean) - .join(' · ')} -
- )} - {!!(r.ckbData || '').trim() && ( -{ - const raw = String(r.ckbData || '').trim() - setCkbDataDialogTitle(`存客宝返回 data(#${r.id})`) - setCkbDataDialogContent(prettyJson(raw)) - setShowCkbDataDialog(true) - }} - > - 查看 data - - )} - {!!r.ckbError && ( -- {r.ckbError} -
- )} -{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} -- ---- {typeof r.retryCount === 'number' ? `第 ${r.retryCount} 次` : '-'} -
---retryLeadPush(r.id)} - className="h-8 px-2.5 text-[11px] border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent shrink-0" - > - -- 重推 - deleteLeadRecord(r.id)} - className="h-8 px-2.5 text-[11px] border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent shrink-0" - > - -- 删除 - - - )} -- ---- 暂无获客线索
-- {debouncedLeadsSearch.trim() || leadsSourceFilter - ? '当前搜索或来源筛选下没有匹配记录,可清空条件后重试。' - : '存客宝场景产生的手机号 / 微信留资会出现在此列表。请确认获客计划已开启,并有用户完成留资。'} -
-loadLeads()} - disabled={leadsLoading} - className="border-[#38bdac]/40 text-[#38bdac] hover:bg-[#38bdac]/10 bg-transparent" - > - -- 重新加载 - {}} - /> - - - {/* ===== 规则配置 ===== */}-- - {/* 流程图 */} -用户从注册到 VIP 的完整行动路径,点击各阶段查看用户动态
-- -刷新数据 - - {/* 连接线 */} - -- - {/* 旅程说明 */} -- {JOURNEY_STAGES.map((stage, idx) => ( --- {/* 阶段卡片 */} -- ))} -loadJourneyUsers(stage.id)} - title={`点击查看「${stage.label}」阶段的用户`} - > -- {/* 箭头(非最后一个)*/} - {idx < JOURNEY_STAGES.length - 1 && ( -{stage.icon}-c.startsWith('text-'))}`}>{stage.label}- {journeyStats[stage.id] !== undefined && ( -- {journeyStats[stage.id]} 人 -- )} - {/* 序号 */} -{idx + 1}-- -- )} - {/* 描述 */} -{stage.desc}
--- - {/* 阶段用户列表(点击阶段卡片后展开) */} - {journeyStage && ( ------ 旅程关键节点 - - {[ - { step: '① 注册', action: '微信 OAuth 或手机号注册', next: '提示设置头像与昵称' }, - { step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' }, - { step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' }, - { step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' }, - { step: '⑤ 分销', action: '推广好友购买', next: '提现分销收益' }, - ].map((item) => ( --- {item.step} -- ))} ---{item.action}
-→ {item.next}
----- {journeyLoading ? ( -- 行为锚点统计 - 实时更新 - -- ) : Object.keys(journeyStats).length > 0 ? ( -- - {(() => { - const totalAll = JOURNEY_STAGES.reduce((s, st) => s + (journeyStats[st.id] || 0), 0) - return JOURNEY_STAGES.map((stage) => { - const count = journeyStats[stage.id] || 0 - const pct = totalAll > 0 ? Math.round((count / totalAll) * 100) : 0 - const barW = count > 0 ? Math.max(pct, 6) : 0 - return ( -- ) : ( -- {stage.icon} {stage.label} -- ) - }) - })()} -- -- {count} - {totalAll > 0 ? `${pct}%` : '—'} --- )} -点击「刷新数据」加载统计
--- )} - - {/* 用户行为轨迹弹窗 */} - --- {journeyUsersLoading ? ( ---- - {JOURNEY_STAGES.find(s => s.id === journeyStage)?.icon}{' '} - {JOURNEY_STAGES.find(s => s.id === journeyStage)?.label} 阶段用户 - - {journeyUsers.length} 人 -setJourneyStage(null)} className="text-gray-400 hover:text-white"> -- ) : journeyUsers.length === 0 ? ( -该阶段暂无用户
- ) : ( --
- )} -- -- -昵称 -手机号 -注册时间 -操作 -- {journeyUsers.map(u => ( - -- - ))} -{u.nickname || '微信用户'} -{u.phone || '-'} -{u.createdAt ? new Date(u.createdAt).toLocaleString('zh-CN') : '-'} -- -loadUserTracks(u.id, u.nickname || '微信用户')}> - -行为轨迹 - @@ -2102,7 +1024,7 @@ export function UsersPage() {@@ -2120,12 +1042,29 @@ export function UsersPage() {- 刷新 { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', triggerConditions: [], actionType: 'popup', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"> + { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', triggerConditions: [], actionType: 'popup', actionConfig: DEFAULT_RULE_ACTION_CONFIG(), sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"> 添加规则 {rules.map((rule) => { const triggerList = normalizeTriggerConditions(rule.triggerConditions) + const ac = normalizeActionConfig(rule.actionConfig) + const needsChapterN = triggerList.some((t) => + ['browse_n_chapters', 'browse_5_chapters', 'view_n_chapters_pay365'].includes(t), + ) + const ck = String(ac.ctaKind || 'default') + const ctaLabel = ck === 'pay365' ? '365支付' : ck === 'share' ? '分享' : '' + const revealLabel = String(ac.revealMode || '') === 'anchor' ? '锚点点击' : '' return ( -+) @@ -2165,149 +1128,9 @@ export function UsersPage() { )} - {/* ===== 超级个体列表(VIP 用户) ===== */} + {/* ===== 超级个体(融合 VIP + @列表 + 首页入口配置) ===== */}- {rule.description && ( -#{rule.sort}+ {rule.enabled && ( + + )} {rule.title} {rule.trigger && {rule.trigger} } {triggerList.length > 0 && ( @@ -2142,21 +1081,45 @@ export function UsersPage() { {ACTION_TYPE_OPTIONS.find((o) => o.value === rule.actionType)?.label || rule.actionType} )} + {needsChapterN && ( ++ N={String(ac.chapterThreshold ?? 5)} + + )} + {ctaLabel && ( ++ {ctaLabel} + + )} + {revealLabel && ck === 'pay365' && ( ++ {revealLabel} + + )}handleToggleRule(rule)} /> - { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: triggerList, actionType: rule.actionType || 'popup', sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"> -handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"> +{ setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: triggerList, actionType: rule.actionType || 'popup', actionConfig: normalizeActionConfig(rule.actionConfig), sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"> +setRuleToDelete(rule)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"> -+ {(String(ac.triggerDisplay || '').trim() || rule.description) && ( +- 查看完整描述 - ({rule.description.length} 字,默认折叠) -
-{rule.description}
-+ {String(ac.triggerDisplay || '').trim() && ( +)}+ 条件文案:{String(ac.triggerDisplay).trim()} +
+ )} + {rule.description && ( +++ )} ++ 查看弹窗/触达主文案 + ({rule.description.length} 字) +
+{rule.description}
+- @@ -2324,82 +1147,6 @@ export function UsersPage() { {/* ===== 弹框组件 ===== */} - {/* 添加/编辑用户 */} - {/* 设置排序 */} - - - {/* 设置超级个体标签 */} - --- - {vipMembersLoading ? ( ---- 展示当前所有有效的超级个体(VIP 用户),用于检查会员信息与排序值。 -
-- 飞书群 Webhook(按人推线索到飞书)已迁至{' '} - 内容管理 → 链接人与事 → @列表 - ,在绑定会员的人物行操作栏配置。 -
-- 提示:按住任意一行即可拖拽排序,释放后将同步更新小程序展示顺序。 -
---- -{' '} - 刷新 - -- ) : vipMembers.length === 0 ? ( -- 加载中... - -- ) : ( -- 当前没有有效的超级个体用户。
-- - )} +- --
-- -- -序号 -成员 -超级个体标签 -头像点击 -获客数 -排序值 -操作 -- {vipMembers.map((m, index) => { - const isDragging = draggingVipId === m.id - const isOver = dragOverVipId === m.id - return ( - -handleVipRowDragStart(e, m.id)} - onDragOver={(e) => handleVipRowDragOver(e, m.id)} - onDrop={(e) => handleVipRowDrop(e, m.id)} - onDragEnd={handleVipRowDragEnd} - className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${ - isDragging ? 'opacity-60' : '' - } ${isOver ? 'bg-[#38bdac]/10' : ''}`} - > - - )})} -{index + 1} -- -- {resolveUserAvatarByMbti(m.avatar, m.mbti) ? ( --{ - (e.target as HTMLImageElement).style.display = 'none' - const fallback = document.createElement('div') - fallback.className = 'w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm' - fallback.textContent = m.name?.[0] || '创'; - (e.target as HTMLImageElement).parentElement?.appendChild(fallback) - }} - /> - ) : ( -
- {m.name?.[0] || '创'} -- )} ---{m.name}-- {m.vipRole || (未设置)} - -- {(() => { - return m.clickCount != null ? String(m.clickCount) : '-' - })()} - -- {(() => { - return m.leadCount != null ? String(m.leadCount) : '-' - })()} - -- {m.vipSort ?? index + 1} - -- ---openVipRoleModal(m)} - title="设置超级个体标签" - > - -- openVipSortModal(m)} - title="设置排序序号" - > - --