From 3cc33c7d7a17660a8855c38a97a7d2c746deaae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Mon, 13 Apr 2026 14:32:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-07-content-typography-hierarchy.md | 38 + .../2026-04-11-阅读付费墙三态与营销句闭环.md | 15 + .../agent/小程序开发工程师/evolution/索引.md | 1 + .cursor/agent/开发助理/项目索引/产品.md | 5 +- .cursor/agent/开发助理/项目索引/助理橙子.md | 3 +- .cursor/agent/开发助理/项目索引/团队.md | 3 +- .cursor/skills/assistant-doc-sync/sync-log.md | 4 + .cursor/skills/change-checklist/SKILL.md | 2 + .cursor/skills/security-server-ops/SKILL.md | 22 + miniprogram/app.js | 119 +- miniprogram/app.json | 2 + .../components/login-modal/login-modal.wxml | 6 +- .../components/login-modal/login-modal.wxss | 24 +- miniprogram/pages/about/about.js | 2 +- miniprogram/pages/about/about.wxml | 2 +- .../avatar-nickname/avatar-nickname.wxml | 4 +- miniprogram/pages/chapters/chapters.js | 148 +- miniprogram/pages/chapters/chapters.wxml | 233 +- miniprogram/pages/chapters/chapters.wxss | 509 ++-- miniprogram/pages/gift-pay/detail.js | 15 +- miniprogram/pages/index/index.js | 492 +++- miniprogram/pages/index/index.wxml | 74 +- miniprogram/pages/index/index.wxss | 393 +++ .../pages/match-history/match-history.js | 80 + .../pages/match-history/match-history.json | 7 + .../pages/match-history/match-history.wxml | 40 + .../pages/match-history/match-history.wxss | 128 + miniprogram/pages/match/match.js | 126 +- miniprogram/pages/match/match.wxml | 63 +- miniprogram/pages/match/match.wxss | 100 +- .../pages/member-detail/member-detail.js | 7 +- .../pages/member-detail/member-detail.wxml | 4 +- miniprogram/pages/my/my.js | 31 +- miniprogram/pages/my/my.wxml | 23 +- miniprogram/pages/my/my.wxss | 3 + .../pages/profile-edit/profile-edit.js | 2 + .../pages/profile-edit/profile-edit.wxml | 20 +- .../pages/profile-show/profile-show.js | 3 +- .../pages/profile-show/profile-show.wxml | 4 +- miniprogram/pages/read/read.js | 433 +++- miniprogram/pages/read/read.wxml | 249 +- miniprogram/pages/read/read.wxss | 489 +++- miniprogram/pages/referral/referral.js | 45 +- miniprogram/pages/referral/referral.json | 4 +- miniprogram/pages/referral/referral.wxml | 8 +- miniprogram/pages/settings/settings.js | 10 +- miniprogram/pages/settings/settings.wxml | 32 +- miniprogram/pages/super-crm/super-crm.js | 179 ++ miniprogram/pages/super-crm/super-crm.json | 4 + miniprogram/pages/super-crm/super-crm.wxml | 71 + miniprogram/pages/super-crm/super-crm.wxss | 151 ++ miniprogram/pages/vip/vip.js | 17 +- miniprogram/pages/wallet/wallet.js | 5 +- miniprogram/project.config.json | 6 +- miniprogram/utils/chapterAccessManager.js | 4 +- miniprogram/utils/contentParser.js | 188 +- miniprogram/utils/mpPagePopups.js | 3 +- miniprogram/utils/partIcons.js | 2 +- miniprogram/utils/ruleEngine.js | 120 +- miniprogram/utils/soulBridge.js | 4 +- scripts/batch_upload_ip_book.py | 193 ++ .../batch_upload_ip_book_preserve_images.py | 269 +++ scripts/deploy_kr_btapi_verify.py | 183 ++ scripts/download_chapter_images_from_md.py | 157 ++ scripts/download_mbti_avatars_16p_colored.py | 89 + scripts/download_mbti_avatars_dicebear.py | 59 + scripts/fix_2026_daily_part.py | 124 + scripts/fix_ip_book_sort_and_images.py | 244 ++ scripts/miniprogram_upload.sh | 4 +- scripts/optimize_28_models.py | 330 +++ scripts/reupload_28_models_html.py | 204 ++ scripts/reupload_gps_html.py | 279 +++ scripts/upload_gps_book.py | 421 ++++ scripts/upload_solo_company_book.py | 367 +++ scripts/wechat_miniprogram_release.py | 8 +- soul-admin/.deploy-manager.sync.json | 15 + soul-admin/package.json | 1 + soul-admin/pnpm-lock.yaml | 29 + soul-admin/src/App.tsx | 83 +- soul-admin/src/api/client.ts | 4 +- soul-admin/src/components/RichEditor.tsx | 10 + .../modules/leads/CkbLeadsPanel.tsx | 1032 ++++++++ .../modules/mbti/MbtiAvatarsManager.tsx | 217 +- .../modules/user/UserDetailModal.tsx | 686 +++--- soul-admin/src/lib/ckbWorkbench.ts | 49 + soul-admin/src/lib/mbtiAvatarPrompts.ts | 112 +- soul-admin/src/pages/api-docs/ApiDocsPage.tsx | 264 +- soul-admin/src/pages/content/ChapterTree.tsx | 91 +- soul-admin/src/pages/content/ContentPage.tsx | 1175 ++++----- .../src/pages/dashboard/DashboardPage.tsx | 587 +++-- .../pages/distribution/DistributionPage.tsx | 680 ++++-- .../pages/find-partner/FindPartnerPage.tsx | 213 +- .../find-partner/tabs/CKBConfigPanel.tsx | 103 +- .../find-partner/tabs/FindPartnerTab.tsx | 28 +- .../pages/find-partner/tabs/MatchPoolTab.tsx | 79 + .../find-partner/tabs/MatchRecordsByType.tsx | 243 ++ .../find-partner/tabs/MentorBookingTab.tsx | 60 +- .../src/pages/find-partner/tabs/MentorTab.tsx | 2 +- .../find-partner/tabs/ResourceDockingTab.tsx | 170 +- .../find-partner/tabs/TeamRecruitTab.tsx | 90 +- soul-admin/src/pages/orders/OrdersPage.tsx | 6 +- .../ReferralSettingsPage.tsx | 174 +- .../src/pages/settings/SettingsPage.tsx | 65 +- .../src/pages/users/HomeEntryConfigTab.tsx | 602 +++++ .../src/pages/users/SuperIndividualTab.tsx | 769 ++++++ soul-admin/src/pages/users/UsersPage.tsx | 2114 ++++------------- soul-admin/tsconfig.tsbuildinfo | 2 +- soul-admin/vite.config.ts | 2 + soul-api/.env copy.development | 69 + soul-api/deploy/Dockerfile | 94 +- soul-api/deploy/README.md | 38 +- soul-api/deploy/docker-compose.yml | 39 +- soul-api/internal/cache/cache.go | 11 +- soul-api/internal/database/database.go | 28 +- .../handler/admin_chapter_click_stats.go | 57 + soul-api/internal/handler/admin_chapters.go | 58 + soul-api/internal/handler/admin_dashboard.go | 140 +- .../internal/handler/admin_distribution.go | 103 + .../internal/handler/admin_mbti_avatars.go | 106 +- soul-api/internal/handler/admin_rfm.go | 111 +- soul-api/internal/handler/admin_user_rules.go | 14 +- soul-api/internal/handler/book.go | 184 +- soul-api/internal/handler/books.go | 352 +++ soul-api/internal/handler/chapter_preview.go | 16 +- soul-api/internal/handler/ckb.go | 43 +- soul-api/internal/handler/ckb_content.go | 143 ++ soul-api/internal/handler/ckb_open.go | 31 + soul-api/internal/handler/db.go | 208 +- soul-api/internal/handler/db_book.go | 29 +- soul-api/internal/handler/db_link_tag.go | 86 +- soul-api/internal/handler/db_person.go | 42 +- soul-api/internal/handler/gift_pay.go | 21 + soul-api/internal/handler/h5_read.go | 31 + soul-api/internal/handler/match.go | 418 +++- soul-api/internal/handler/match_records.go | 92 + soul-api/internal/handler/mentor.go | 35 +- soul-api/internal/handler/miniprogram.go | 623 ++++- .../handler/miniprogram_mbti_avatars.go | 8 +- .../handler/miniprogram_my_super_crm.go | 289 +++ .../handler/miniprogram_super_stats.go | 42 + soul-api/internal/handler/order_webhook.go | 40 +- soul-api/internal/handler/referral.go | 228 ++ .../internal/handler/referral_commission.go | 3 + soul-api/internal/handler/request_origin.go | 59 + soul-api/internal/handler/user.go | 98 +- soul-api/internal/handler/vip.go | 116 +- .../internal/handler/vip_members_admin.go | 94 +- soul-api/internal/handler/wechat.go | 2 +- soul-api/internal/handler/ws.go | 19 +- soul-api/internal/middleware/ratelimit.go | 54 + soul-api/internal/model/book.go | 18 + soul-api/internal/model/chapter.go | 1 + soul-api/internal/model/link_tag.go | 25 +- .../internal/model/mentor_consultation.go | 2 +- soul-api/internal/model/person.go | 19 +- soul-api/internal/model/user.go | 3 + soul-api/internal/router/router.go | 33 +- soul-api/internal/wechat/miniprogram.go | 7 + soul-api/master.py | 200 +- soul-api/文档索引.md | 38 +- static/mbti-avatars-png-female/ENFJ.png | Bin 0 -> 19601 bytes static/mbti-avatars-png-female/ENFP.png | Bin 0 -> 22491 bytes static/mbti-avatars-png-female/ENTJ.png | Bin 0 -> 17940 bytes static/mbti-avatars-png-female/ENTP.png | Bin 0 -> 16325 bytes static/mbti-avatars-png-female/ESFJ.png | Bin 0 -> 15766 bytes static/mbti-avatars-png-female/ESFP.png | Bin 0 -> 22377 bytes static/mbti-avatars-png-female/ESTJ.png | Bin 0 -> 19417 bytes static/mbti-avatars-png-female/ESTP.png | Bin 0 -> 22291 bytes static/mbti-avatars-png-female/INFJ.png | Bin 0 -> 17627 bytes static/mbti-avatars-png-female/INFP.png | Bin 0 -> 21688 bytes static/mbti-avatars-png-female/INTJ.png | Bin 0 -> 19947 bytes static/mbti-avatars-png-female/INTP.png | Bin 0 -> 22125 bytes static/mbti-avatars-png-female/ISFJ.png | Bin 0 -> 14918 bytes static/mbti-avatars-png-female/ISFP.png | Bin 0 -> 23354 bytes static/mbti-avatars-png-female/ISTJ.png | Bin 0 -> 19038 bytes static/mbti-avatars-png-female/ISTP.png | Bin 0 -> 20261 bytes static/mbti-avatars-png-male/ENFJ.png | Bin 0 -> 16035 bytes static/mbti-avatars-png-male/ENFP.png | Bin 0 -> 20230 bytes static/mbti-avatars-png-male/ENTJ.png | Bin 0 -> 17574 bytes static/mbti-avatars-png-male/ENTP.png | Bin 0 -> 18306 bytes static/mbti-avatars-png-male/ESFJ.png | Bin 0 -> 26599 bytes static/mbti-avatars-png-male/ESFP.png | Bin 0 -> 17755 bytes static/mbti-avatars-png-male/ESTJ.png | Bin 0 -> 18452 bytes static/mbti-avatars-png-male/ESTP.png | Bin 0 -> 20708 bytes static/mbti-avatars-png-male/INFJ.png | Bin 0 -> 21820 bytes static/mbti-avatars-png-male/INFP.png | Bin 0 -> 21959 bytes static/mbti-avatars-png-male/INTJ.png | Bin 0 -> 18845 bytes static/mbti-avatars-png-male/INTP.png | Bin 0 -> 23307 bytes static/mbti-avatars-png-male/ISFJ.png | Bin 0 -> 21633 bytes static/mbti-avatars-png-male/ISFP.png | Bin 0 -> 19774 bytes static/mbti-avatars-png-male/ISTJ.png | Bin 0 -> 17120 bytes static/mbti-avatars-png-male/ISTP.png | Bin 0 -> 21593 bytes 开发文档/10、项目管理/README.md | 24 +- 开发文档/1、需求/2026-03-20-需求.md | 10 +- 开发文档/6、后端/README.md | 27 +- 开发文档/8、部署/README.md | 29 +- 开发文档/索引.md | 202 +- 197 files changed, 17286 insertions(+), 5095 deletions(-) create mode 100644 .cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md create mode 100644 .cursor/agent/小程序开发工程师/evolution/2026-04-11-阅读付费墙三态与营销句闭环.md create mode 100644 miniprogram/pages/match-history/match-history.js create mode 100644 miniprogram/pages/match-history/match-history.json create mode 100644 miniprogram/pages/match-history/match-history.wxml create mode 100644 miniprogram/pages/match-history/match-history.wxss create mode 100644 miniprogram/pages/super-crm/super-crm.js create mode 100644 miniprogram/pages/super-crm/super-crm.json create mode 100644 miniprogram/pages/super-crm/super-crm.wxml create mode 100644 miniprogram/pages/super-crm/super-crm.wxss create mode 100644 scripts/batch_upload_ip_book.py create mode 100644 scripts/batch_upload_ip_book_preserve_images.py create mode 100644 scripts/deploy_kr_btapi_verify.py create mode 100644 scripts/download_chapter_images_from_md.py create mode 100644 scripts/download_mbti_avatars_16p_colored.py create mode 100644 scripts/download_mbti_avatars_dicebear.py create mode 100644 scripts/fix_2026_daily_part.py create mode 100644 scripts/fix_ip_book_sort_and_images.py create mode 100644 scripts/optimize_28_models.py create mode 100644 scripts/reupload_28_models_html.py create mode 100644 scripts/reupload_gps_html.py create mode 100644 scripts/upload_gps_book.py create mode 100644 scripts/upload_solo_company_book.py create mode 100644 soul-admin/.deploy-manager.sync.json create mode 100644 soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx create mode 100644 soul-admin/src/lib/ckbWorkbench.ts create mode 100644 soul-admin/src/pages/find-partner/tabs/MatchRecordsByType.tsx create mode 100644 soul-admin/src/pages/users/HomeEntryConfigTab.tsx create mode 100644 soul-admin/src/pages/users/SuperIndividualTab.tsx create mode 100644 soul-api/.env copy.development create mode 100644 soul-api/internal/handler/admin_chapter_click_stats.go create mode 100644 soul-api/internal/handler/books.go create mode 100644 soul-api/internal/handler/ckb_content.go create mode 100644 soul-api/internal/handler/miniprogram_my_super_crm.go create mode 100644 soul-api/internal/handler/miniprogram_super_stats.go create mode 100644 soul-api/internal/handler/request_origin.go create mode 100644 soul-api/internal/model/book.go create mode 100644 static/mbti-avatars-png-female/ENFJ.png create mode 100644 static/mbti-avatars-png-female/ENFP.png create mode 100644 static/mbti-avatars-png-female/ENTJ.png create mode 100644 static/mbti-avatars-png-female/ENTP.png create mode 100644 static/mbti-avatars-png-female/ESFJ.png create mode 100644 static/mbti-avatars-png-female/ESFP.png create mode 100644 static/mbti-avatars-png-female/ESTJ.png create mode 100644 static/mbti-avatars-png-female/ESTP.png create mode 100644 static/mbti-avatars-png-female/INFJ.png create mode 100644 static/mbti-avatars-png-female/INFP.png create mode 100644 static/mbti-avatars-png-female/INTJ.png create mode 100644 static/mbti-avatars-png-female/INTP.png create mode 100644 static/mbti-avatars-png-female/ISFJ.png create mode 100644 static/mbti-avatars-png-female/ISFP.png create mode 100644 static/mbti-avatars-png-female/ISTJ.png create mode 100644 static/mbti-avatars-png-female/ISTP.png create mode 100644 static/mbti-avatars-png-male/ENFJ.png create mode 100644 static/mbti-avatars-png-male/ENFP.png create mode 100644 static/mbti-avatars-png-male/ENTJ.png create mode 100644 static/mbti-avatars-png-male/ENTP.png create mode 100644 static/mbti-avatars-png-male/ESFJ.png create mode 100644 static/mbti-avatars-png-male/ESFP.png create mode 100644 static/mbti-avatars-png-male/ESTJ.png create mode 100644 static/mbti-avatars-png-male/ESTP.png create mode 100644 static/mbti-avatars-png-male/INFJ.png create mode 100644 static/mbti-avatars-png-male/INFP.png create mode 100644 static/mbti-avatars-png-male/INTJ.png create mode 100644 static/mbti-avatars-png-male/INTP.png create mode 100644 static/mbti-avatars-png-male/ISFJ.png create mode 100644 static/mbti-avatars-png-male/ISFP.png create mode 100644 static/mbti-avatars-png-male/ISTJ.png create mode 100644 static/mbti-avatars-png-male/ISTP.png diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md new file mode 100644 index 00000000..d91bef0a --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-04-07-content-typography-hierarchy.md @@ -0,0 +1,38 @@ +# 内容排版层级改造 (2026-04-07) + +## 问题 +28套私域商业模式章节在小程序中显示为纯文本流,所有内容同样字体大小,无层级感。h2/h3 被扁平化为普通 text,列表项带孤立 bullet 点(•),无缩进。 + +## 解决方案 + +### contentParser.js 新增段落类型 +- `heading` (level 2-6):从 `

`~`

` 提取,用占位符 `__H_N__` 暂存 +- `quote`:从 `
` 提取 +- `listItem` (ordered/unordered):从 `
  • ` 提取,区分 `
      ` 和 `
        ` 上下文 +- 保留 table / image / text / mention / linkTag + +### 解析流程(顺序关键) +1. 提取 `` → 占位 +2. 提取 `

        `~`

        ` → 占位 +3. 提取 `
        ` → 占位 +4. 处理 `
          /
        HTML 为结构化数据 { headers: string[], rows: string[][] } + */ +function parseTableHtml(tableHtml) { + const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim()) + const headers = [] + const rows = [] + + const headMatch = tableHtml.match(/]*>([\s\S]*?)<\/thead>/i) + if (headMatch) { + const thRe = /]*>([\s\S]*?)<\/th>/gi + let m + while ((m = thRe.exec(headMatch[1])) !== null) { + headers.push(stripTags(m[1])) + } + } + + const bodyMatch = tableHtml.match(/]*>([\s\S]*?)<\/tbody>/i) + const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml + const trRe = /]*>([\s\S]*?)<\/tr>/gi + let trM + let isFirst = !headMatch + while ((trM = trRe.exec(bodyContent)) !== null) { + if (headMatch && trM[0] === (headMatch[0].match(/]*>[\s\S]*?<\/tr>/i) || [])[0]) continue + const cells = [] + const cellRe = /]*>([\s\S]*?)<\/t[dh]>/gi + let cM + while ((cM = cellRe.exec(trM[1])) !== null) { + cells.push(stripTags(cM[1])) + } + if (!cells.length) continue + if (isFirst && !headers.length) { + headers.push(...cells) + isFirst = false + } else { + rows.push(cells) + } + } + return { headers, rows } +} + /** * 从 HTML 中解析出 lines(纯文本行)和 segments(含富文本片段) + * segment 新增类型:heading / quote / listItem * @param {string} html - * @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签;assetBase 用于补全图片相对 URL + * @param {object} [config] - { persons: [], linkTags: [], assetBase?: string } */ function parseHtmlToSegments(html, config) { const lines = [] const segments = [] - // 1. 块级标签换行,保留内联标签供后续解析 - let text = html + // 0. 提取
        块 + const tables = [] + let text = html.replace(/]*>[\s\S]*?<\/table>/gi, (match) => { + const idx = tables.length + tables.push(parseTableHtml(match)) + return '\n__TABLE_' + idx + '__\n' + }) + + // 1. 提取

        /

        → heading 占位 + const headings = [] + text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) { + const idx = headings.length + headings.push({ level: parseInt(lvl, 10), raw: inner.trim() }) + return '\n__H_' + idx + '__\n' + }) + + // 2. 提取
        → quote 占位 + const quotes = [] + text = text.replace(/]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) { + const idx = quotes.length + quotes.push(inner.trim()) + return '\n__Q_' + idx + '__\n' + }) + + // 3. 列表 → listItem 占位(保留 ordered 标记) + var olDepth = 0 + var olCounter = 0 + text = text.replace(/]*>/gi, function () { olDepth++; olCounter = 0; return '\n' }) + text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' }) + text = text.replace(/]*>/gi, '\n') + text = text.replace(/<\/ul>/gi, '\n') + text = text.replace(/]*>([\s\S]*?)<\/li>/gi, function (_, inner) { + var cleaned = inner.replace(/]*>/gi, '').replace(/<\/p>/gi, '').trim() + if (!cleaned) return '\n' + if (olDepth > 0) { + olCounter++ + return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n' + } + return '\n__LI_U__ ' + cleaned + '\n' + }) + + // 4. 块级标签换行 text = text.replace(/<\/p>\s*]*>/gi, '\n\n') text = text.replace(/]*>/gi, '') text = text.replace(/<\/p>/gi, '\n') text = text.replace(/]*>/gi, '') text = text.replace(/<\/div>/gi, '\n') text = text.replace(//gi, '\n') - text = text.replace(/<\/?h[1-6][^>]*>/gi, '\n') - text = text.replace(/<\/?blockquote[^>]*>/gi, '\n') - text = text.replace(/<\/?ul[^>]*>/gi, '\n') - text = text.replace(/<\/?ol[^>]*>/gi, '\n') - text = text.replace(/]*>/gi, '• ') - text = text.replace(/<\/li>/gi, '\n') + text = text.replace(//gi, '\n') - // 2. 逐段解析 - const blocks = text.split(/\n+/) - for (const block of blocks) { + // 5. 逐段解析 + var blocks = text.split(/\n+/) + for (var bi = 0; bi < blocks.length; bi++) { + var block = blocks[bi] if (!block.trim()) continue - let blockSegs = parseBlockToSegments(block, config) + // table + var tblM = block.trim().match(/^__TABLE_(\d+)__$/) + if (tblM) { + var tbl = tables[parseInt(tblM[1], 10)] + if (tbl) { + lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n')) + segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }]) + } + continue + } + + // heading + var hM = block.trim().match(/^__H_(\d+)__$/) + if (hM) { + var h = headings[parseInt(hM[1], 10)] + if (h) { + var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim()) + lines.push(hText) + segments.push([{ type: 'heading', level: h.level, text: hText }]) + } + continue + } + + // quote + var qM = block.trim().match(/^__Q_(\d+)__$/) + if (qM) { + var qHtml = quotes[parseInt(qM[1], 10)] + if (qHtml) { + var qText = decodeEntities( + qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim() + ).replace(/\n{2,}/g, '\n') + lines.push(qText) + segments.push([{ type: 'quote', text: qText }]) + } + continue + } + + // ordered list item + var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/) + if (liOM) { + var liNum = parseInt(liOM[1], 10) + var liContent = liOM[2].trim() + var liSegs = parseBlockToSegments(liContent, config) + var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim()) + lines.push(liNum + '. ' + liText) + segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }]) + continue + } + + // unordered list item + var liUM = block.match(/^__LI_U__\s*(.*)/) + if (liUM) { + var liContent2 = liUM[1].trim() + var liSegs2 = parseBlockToSegments(liContent2, config) + var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim()) + lines.push(liText2) + segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }]) + continue + } + + // 普通段落 + var blockSegs = parseBlockToSegments(block, config) if (!blockSegs.length) continue - // 纯图片行独立成段 if (blockSegs.length === 1 && blockSegs[0].type === 'image') { lines.push('') segments.push(blockSegs) continue } - // 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx) if (config && (config.persons?.length || config.linkTags?.length)) { - const expanded = [] - for (const seg of blockSegs) { + var expanded = [] + for (var si = 0; si < blockSegs.length; si++) { + var seg = blockSegs[si] if (seg.type === 'text' && seg.text) { - const sub = matchLineToSegments(seg.text, config) - expanded.push(...sub) + expanded.push.apply(expanded, matchLineToSegments(seg.text, config)) } else { expanded.push(seg) } @@ -223,8 +364,7 @@ function parseHtmlToSegments(html, config) { blockSegs = expanded } - // 行纯文本用于 lines(previewParagraphs 降级展示) - const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim() + var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim() lines.push(lineText) segments.push(blockSegs) } @@ -327,7 +467,9 @@ function matchLineToSegments(line, config) { pagePath: tag.pagePath || '', tagId: tag.tagId || '', appId: tag.appId || '', - mpKey: tag.mpKey || '' + mpKey: tag.mpKey || '', + passPhone: !!tag.passPhone, + phoneParamName: tag.phoneParamName || 'phone' }) } else { segs.push({ type: 'text', text: full }) diff --git a/miniprogram/utils/mpPagePopups.js b/miniprogram/utils/mpPagePopups.js index 05c9be5a..6eaf5162 100644 --- a/miniprogram/utils/mpPagePopups.js +++ b/miniprogram/utils/mpPagePopups.js @@ -14,7 +14,8 @@ const READ_PATH = '/pages/read/read' /** 阅读页 mpUi 兜底(pagePopupItems / readPage 均无或为空时) */ const READ_PAGE_FALLBACK = { - beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。', + /** 已不在付费墙展示长说明;保留短句供兼容旧配置拉取 */ + beforeLoginHint: '', singlePageTitle: '解锁完整内容', singlePagePaywallHint: '当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。', diff --git a/miniprogram/utils/partIcons.js b/miniprogram/utils/partIcons.js index 24f59c5f..3444eb18 100644 --- a/miniprogram/utils/partIcons.js +++ b/miniprogram/utils/partIcons.js @@ -2,7 +2,7 @@ * 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致 * 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时) */ -const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖'] +const PART_ICONS = ['🧭', '🌍', '⚡', '💎', '🏆', '🚀', '🎯', '🔑', '🌟', '🎪'] /** 正文篇在列表中的从 0 开始的序号 → emoji(与 ChapterTree bodyPartOrdinal 一致) */ function partEmojiForBodyIndex(bodyIndex) { diff --git a/miniprogram/utils/ruleEngine.js b/miniprogram/utils/ruleEngine.js index 2f221679..3ce4490c 100644 --- a/miniprogram/utils/ruleEngine.js +++ b/miniprogram/utils/ruleEngine.js @@ -412,6 +412,90 @@ function checkRule_Withdraw(rules) { } } +/** + * 浏览 N 章后弹出 365 读书会支付引导 + * 规则匹配:triggerConditions 包含 view_n_chapters_pay365 或 browse_n_chapters + * 阈值来自 actionConfig.chapterThreshold(默认 3) + * actionConfig.revealMode:modal 进入章节即延时弹窗;anchor 仅展示锚点条,用户点击后再弹窗/支付 + * actionConfig.anchorLabel:锚点条文案(可选) + */ +function findRuleByTriggerCondition(rules, conditionKey) { + return rules.find(r => { + if (r.completed) return false + const tc = r.triggerConditions + if (Array.isArray(tc)) return tc.includes(conditionKey) + if (typeof tc === 'string') { + try { + const parsed = JSON.parse(tc) + if (Array.isArray(parsed)) return parsed.includes(conditionKey) + } catch (_) {} + } + return false + }) +} + +function parseActionConfig(rule) { + if (!rule) return {} + const ac = rule.actionConfig + if (ac && typeof ac === 'object' && !Array.isArray(ac)) return ac + if (typeof ac === 'string' && ac.trim()) { + try { + const p = JSON.parse(ac) + if (p && typeof p === 'object') return p + } catch (_) {} + } + return {} +} + +function checkRule_ViewNChaptersPay365(rules) { + const rule = findRuleByTriggerCondition(rules, 'view_n_chapters_pay365') + || findRuleByTriggerCondition(rules, 'browse_n_chapters') + if (!rule) return null + + const user = getUserInfo() + if (!user.id) return null + + const app = getAppInstance() + if (!app) return null + if (app.globalData.hasFullBook || app.globalData.isVip) return null + + const ac = parseActionConfig(rule) + const threshold = (Number(ac.chapterThreshold) > 0) ? Number(ac.chapterThreshold) : 3 + const fullRead = + typeof app.getReadCount === 'function' ? app.getReadCount() : app.globalData.readCount || 0 + const browseDistinct = + typeof app.getBrowseDistinctChapterCount === 'function' ? app.getBrowseDistinctChapterCount() : 0 + const readCount = Math.max(Number(fullRead) || 0, Number(browseDistinct) || 0) + if (readCount < threshold) return null + + if (isInCooldown('view_n_pay365')) return null + setCooldown('view_n_pay365') + + const ctaKind = String(ac.ctaKind || 'pay365') + const amount = ctaKind === 'pay365' ? 365 : null + + const rm = ac.revealMode + let revealMode = rm === 'modal' || rm === 'anchor' ? rm : null + if (!revealMode) { + revealMode = 'modal' + } + const anchorLabel = trimStr(ac.anchorLabel) + + return { + ruleId: 'view_n_pay365', + serverRuleId: rule.id, + title: rule.title || '加入 365 读书会', + message: rule.description || `你已阅读 ${readCount} 个章节,加入 365 读书会可解锁全部内容,一年内不限次阅读。`, + confirmText: amount ? `¥${amount} 立即加入` : '去了解', + cancelText: '再看看', + action: ctaKind === 'pay365' ? 'pay365' : 'navigate', + target: null, + amount, + revealMode, + anchorLabel, + } +} + function checkRulesSync(scene, rules) { const user = getUserInfo() if (!user.id) return null @@ -431,6 +515,8 @@ function checkRulesSync(scene, rules) { return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules) case 'before_join_party': return checkRule_JoinParty(rules) + case 'chapter_read': + return checkRule_ViewNChaptersPay365(rules) default: return null } @@ -452,6 +538,12 @@ function executeRule(rule, pageInstance) { if (typeof pageInstance.showPhoneBinding === 'function') { pageInstance.showPhoneBinding() } + } else if (rule.action === 'pay365' && pageInstance) { + if (typeof pageInstance.handlePurchaseFullBook === 'function') { + pageInstance.handlePurchaseFullBook() + } else if (typeof pageInstance.processPayment === 'function') { + pageInstance.processPayment('fullbook', null, rule.amount || 365) + } } if (rule.serverRuleId) { markRuleCompleted(rule.serverRuleId) @@ -485,8 +577,34 @@ async function checkAndExecute(scene, pageInstance) { rule = checkRulesSync(scene, rules) } if (rule) { + if ( + scene === 'chapter_read' && + rule.ruleId === 'view_n_pay365' && + rule.revealMode === 'anchor' && + rule.action === 'pay365' && + pageInstance && + typeof pageInstance.onPay365MarketingAnchor === 'function' + ) { + setTimeout(() => pageInstance.onPay365MarketingAnchor(rule), 800) + return + } setTimeout(() => executeRule(rule, pageInstance), 800) } } -module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted } +/** 阅读页同步营销态:与 chapter_read 同一套条件,不自动弹窗 */ +async function getChapterReadMarketingRule() { + const rules = await loadRules() + const user = getUserInfo() + if (!user.id) return null + return checkRulesSync('chapter_read', rules) +} + +module.exports = { + checkRules: checkRulesSync, + executeRule, + checkAndExecute, + loadRules, + markRuleCompleted, + getChapterReadMarketingRule, +} diff --git a/miniprogram/utils/soulBridge.js b/miniprogram/utils/soulBridge.js index a780f6e1..845f3699 100644 --- a/miniprogram/utils/soulBridge.js +++ b/miniprogram/utils/soulBridge.js @@ -18,9 +18,9 @@ function getReferralCodeForPay(app) { return '' } -/** 章节 / 全书支付描述(与 read 页原逻辑一致) */ +/** 章节 / 365读书会(fullbook)支付描述(与 read 页一致) */ function buildSectionPayDescription(productType, sectionId, sectionTitle) { - if (productType === 'fullbook') return '《一场Soul的创业实验》全书' + if (productType === 'fullbook') return '加入读书会' if (productType === 'section') { const t = sectionTitle || sectionId || '' const short = t.length > 20 ? t.slice(0, 20) + '...' : t diff --git a/scripts/batch_upload_ip_book.py b/scripts/batch_upload_ip_book.py new file mode 100644 index 00000000..a6aa4293 --- /dev/null +++ b/scripts/batch_upload_ip_book.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量上传《卡若的IP财富旅程》PART1-5 到小程序。 + +part_id : part-new-17755513182 +part_title: 卡若的IP财富旅程 +各 PART 对应独立 chapter: + PART1 -> chapter-ip-1 | PART1 | 我少年时期的互联网启蒙 + PART2 -> chapter-ip-2 | PART2 | 从0-1的过程 + PART3 -> chapter-ip-3 | PART3 | 我的创业旅程 + PART4 -> chapter-ip-4 | PART4 | 私域流量到AI时代的商业进化 + PART5 -> chapter-ip-5 | PART5 | 未来的一些思考 + +ID 格式: ip. 例: ip1.1, ip2.10, ip3.5 +""" +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +# 匹配 ✅1.5-标题 或 ✅1.5- 标题 形式的文件名,提取 section_num 和 title +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + """返回 [(ip_id, title, path)] 已去重且按节号排序""" + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + # 去掉 .md 后缀再匹配(处理双后缀 .md.md) + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + # 把文件名里的冒号/括号等清理出来作为标题 + title = title_raw.strip(":: \t") + # 还原完整标题(包含冒号) + title = title_raw + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + + # 优先选有 .md 后缀的文件(去重) + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + # 若新文件有 .md 后缀,替换 + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + # 按节号排序 + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, ip_id), + ) + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_skip = 0 + total_err = 0 + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for ip_id, title, path in files: + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败 {path.name}: {e}") + total_err += 1 + continue + + body = for_miniprogram_body(strip_md_title_line(raw)) + if dry_run: + print(f" [dry] {ip_id} {title[:30]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter(cur, chapter_id, chapter_title, ip_id, title, body, price) + conn.commit() + print(f" ✓ [{action}] {ip_id} {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 跳过 {total_skip} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/batch_upload_ip_book_preserve_images.py b/scripts/batch_upload_ip_book_preserve_images.py new file mode 100644 index 00000000..6dddeabc --- /dev/null +++ b/scripts/batch_upload_ip_book_preserve_images.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +《卡若的IP财富旅程》PART1-5 重新上传:正文以本地 MD 为准,已入库的图片块按顺序保留(不重新上传)。 + +- 从 chapters.content 提取现有

        (或裸 )块,顺序与 MD 中独立图片行一一对应。 +- 无图片的章节:与 batch_upload_ip_book.py 相同(纯文本清洗)。 +- 同步 sort_order(与 fix_ip_book_sort_and_images 一致)。 +""" +from __future__ import annotations + +import html +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《卡若的IP财富旅程》") + +PART_ID = "part-new-17755513182" +PART_TITLE = "卡若的IP财富旅程" + +CHAPTERS = { + 1: ("chapter-ip-1", "PART1 | 我少年时期的互联网启蒙"), + 2: ("chapter-ip-2", "PART2 | 从0-1的过程"), + 3: ("chapter-ip-3", "PART3 | 我的创业旅程"), + 4: ("chapter-ip-4", "PART4 | 私域流量到AI时代的商业进化"), + 5: ("chapter-ip-5", "PART5 | 未来的一些思考"), +} + +FILENAME_RE = re.compile( + r"^[✅⬜]?\s*(\d+)\.(\d+)[- ]+(.+?)(?:\.md)?$", + re.UNICODE, +) + +LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def strip_md_title_line(text: str) -> str: + lines = text.splitlines() + if lines and lines[0].lstrip().startswith("#"): + return "\n".join(lines[1:]).lstrip("\n") + return text + + +def for_miniprogram_body_plain(text: str) -> str: + out_lines: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def extract_img_blocks_from_db(old_content: str) -> list[str]: + """从已入库正文中按文档顺序提取图片块(供复用 URL)。""" + if not old_content or "]*>\s*(]+/?>)\s*

        ", s, re.IGNORECASE | re.DOTALL): + blocks.append(m.group(0).strip()) + if blocks: + return blocks + for m in re.finditer(r"]+/?>", s, re.IGNORECASE): + blocks.append(f"

        {m.group(0).strip()}

        ") + return blocks + + +def body_html_preserve_images(md_path: Path, old_content: str) -> tuple[str, list[str]]: + """ + 按 fix_ip_book 的 HTML 段落规则生成正文,图片行使用旧库中的块。 + 返回 (body, warnings) + """ + warnings: list[str] = [] + old_imgs = extract_img_blocks_from_db(old_content) + raw = md_path.read_text(encoding="utf-8") + lines = strip_md_title_line(raw).splitlines() + + img_idx = 0 + md_img_lines = sum(1 for ln in lines if LINE_IMAGE_ONLY.match(ln)) + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + if img_idx < len(old_imgs): + chunks.append(old_imgs[img_idx]) + img_idx += 1 + else: + warnings.append(f"MD 图片行多于库中已存图片 ({md_path.name}),已跳过该行图片") + chunks.append("

        (图片:库中无对应已上传块,请检查)

        ") + continue + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"

        {html.escape(stripped)}

        ") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + body = "\n".join(html_parts).strip() + "\n" + + rest = len(old_imgs) - img_idx + if rest > 0: + warnings.append(f"{md_path.name}: 库中多 {rest} 张图片未在 MD 中引用,已从新正文移除(按 MD 为准)") + if md_img_lines and not old_imgs: + warnings.append(f"{md_path.name}: MD 含图片行但库中正文无 ,请先跑 fix_ip_book_sort_and_images 上传图片,或使用纯文本上传") + + return body, warnings + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + if not part_dir.exists(): + print(f" ⚠ 目录不存在: {part_dir}", file=sys.stderr) + return [] + + seen_sections: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + name = f.name + clean_name = re.sub(r"\.md(\.md)*$", "", name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean_name) + if not m: + print(f" ⚠ 跳过无法解析的文件: {name}") + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + if int(p_num) != part_num: + print(f" ⚠ PART号不匹配,跳过: {name}") + continue + ip_id = f"ip{part_num}.{s_num}" + section_key = f"{part_num}.{s_num}" + title = title_raw + if section_key in seen_sections: + existing_path = seen_sections[section_key][2] + if str(f).lower().endswith(".md") and not str(existing_path).lower().endswith(".md"): + seen_sections[section_key] = (ip_id, title, f) + else: + seen_sections[section_key] = (ip_id, title, f) + + result = list(seen_sections.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def md_has_image_only_lines(md_path: Path) -> bool: + raw = md_path.read_text(encoding="utf-8") + text = strip_md_title_line(raw) + return any(LINE_IMAGE_ONLY.match(ln) for ln in text.splitlines()) + + +def upsert_chapter(cur, chapter_id: str, chapter_title: str, ip_id: str, title: str, body: str, price: float, sort_order: int): + cur.execute("SELECT id FROM chapters WHERE id = %s", (ip_id,)) + if cur.fetchone(): + cur.execute( + "UPDATE chapters SET section_title=%s, content=%s, part_id=%s, part_title=%s, " + "chapter_id=%s, chapter_title=%s, price=%s, sort_order=%s WHERE id=%s", + (title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order, ip_id), + ) + return "updated" + cur.execute( + "INSERT INTO chapters (id, section_title, content, part_id, part_title, chapter_id, chapter_title, price, sort_order) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)", + (ip_id, title, body, PART_ID, PART_TITLE, chapter_id, chapter_title, price, sort_order), + ) + return "created" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + total_ok = 0 + total_err = 0 + all_warnings: list[str] = [] + + for part_num, (chapter_id, chapter_title) in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} → {chapter_id} | {chapter_title}") + print(f"{'='*60}") + files = collect_files(part_num) + if not files: + print(" 无文件,跳过") + continue + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + try: + has_imgs = md_has_image_only_lines(path) + if has_imgs: + cur.execute("SELECT content FROM chapters WHERE id = %s", (ip_id,)) + row = cur.fetchone() + old_content = row[0] if row and row[0] else "" + body, warns = body_html_preserve_images(path, old_content) + for w in warns: + all_warnings.append(f"{ip_id}: {w}") + else: + raw = path.read_text(encoding="utf-8") + body = for_miniprogram_body_plain(strip_md_title_line(raw)) + except Exception as e: + print(f" ✗ 处理失败 {ip_id} ({path.name}): {e}") + total_err += 1 + continue + + if dry_run: + mode = "html+保留图" if has_imgs else "plain" + print(f" [dry] {ip_id} sort={sort_order} [{mode}] {title[:36]} ({path.name})") + total_ok += 1 + continue + + try: + action = upsert_chapter( + cur, chapter_id, chapter_title, ip_id, title, body, price, sort_order + ) + conn.commit() + mode = "html" if has_imgs else "plain" + print(f" ✓ [{action}] {ip_id} sort={sort_order} [{mode}] {title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 上传失败 {ip_id} ({path.name}): {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if all_warnings: + print("\n⚠ 提示:") + for w in all_warnings[:50]: + print(f" - {w}") + if len(all_warnings) > 50: + print(f" ... 另有 {len(all_warnings) - 50} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/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 中抓取远程图片(![alt](url) 与 ),下载到本地 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'

        {html.escape(alt)}

        ') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"

        (图片路径无效:{html.escape(ref)})

        ") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'

        {html.escape(alt)}

        ') + continue + # 普通文字行 + stripped = line.replace("**", "").strip() + if stripped: + chunks.append(f"

        {html.escape(stripped)}

        ") + else: + chunks.append("") + + # 压缩多余空行 + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def plain_body(md_path: Path) -> str: + """无图片章节:纯文本清洗(与原 batch_upload 一致)""" + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines).replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def collect_files(part_num: int) -> list[tuple[str, str, Path]]: + part_dir = BOOK_DIR / f"PART{part_num}" + seen: dict[str, tuple[str, str, Path]] = {} + for f in part_dir.iterdir(): + clean = re.sub(r"\.md(\.md)*$", "", f.name, flags=re.IGNORECASE) + m = FILENAME_RE.match(clean) + if not m or int(m.group(1)) != part_num: + continue + p_num, s_num, title_raw = m.group(1), m.group(2), m.group(3).strip() + ip_id = f"ip{p_num}.{s_num}" + key = f"{p_num}.{s_num}" + if key in seen: + if str(f).lower().endswith(".md") and not str(seen[key][2]).lower().endswith(".md"): + seen[key] = (ip_id, title_raw, f) + else: + seen[key] = (ip_id, title_raw, f) + result = list(seen.values()) + result.sort(key=lambda x: float(re.sub(r"ip\d+\.", "", x[0]))) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_sort_fixed = 0 + total_img_updated = 0 + total_plain_updated = 0 + + for part_num, chapter_id in CHAPTERS.items(): + print(f"\n{'='*60}") + print(f"PART{part_num} ({chapter_id})") + print(f"{'='*60}") + files = collect_files(part_num) + + for sort_order, (ip_id, title, path) in enumerate(files, start=1): + has_imgs = has_image_refs(path) + img_tag = "🖼" if has_imgs else " " + print(f"\n {img_tag} {ip_id} sort={sort_order} {title[:35]} ({path.name})") + + if has_imgs: + body = md_to_html(path, upload_cache, dry_run) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_img_updated += 1 + total_sort_fixed += 1 + else: + body = plain_body(path) + if not dry_run: + cur.execute( + "UPDATE chapters SET sort_order=%s, content=%s WHERE id=%s", + (sort_order, body, ip_id), + ) + conn.commit() + total_plain_updated += 1 + total_sort_fixed += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"排序修复: {total_sort_fixed} 条") + print(f"图片章节更新: {total_img_updated} 条") + print(f"纯文本章节更新: {total_plain_updated} 条") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/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"![{alt}]({upload_cache[key]})" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def preprocess_markdown(md_text: str) -> str: + """预处理 Markdown 源文本,解决嵌套列表产生的孤立 bullet 问题""" + lines = md_text.splitlines() + + # 去掉第一行如果是 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + result = [] + for line in lines: + # 把 "---" 分隔线去掉(标题已经能分隔段落) + if re.match(r"^\s*---+\s*$", line): + continue + result.append(line) + + # 合并嵌套列表:把 " - xxx" 子项合并为同级 "- xxx" + flat = [] + for line in result: + # 检测 2 空格或 4 空格缩进的子列表项 + m = re.match(r"^(\s{2,4})[-*]\s+(.*)", line) + if m: + flat.append(f"- {m.group(2)}") + else: + flat.append(line) + + return "\n".join(flat).strip() + + +def md_to_html(md_text: str) -> str: + """Markdown → 干净的 HTML""" + cleaned = preprocess_markdown(md_text) + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = clean_html(html) + return html.strip() + + +def clean_html(html: str) -> str: + """清理 HTML:去


        、去
      • 、合并空标签、修孤立 bullet""" + + # 去掉所有


        标签 + html = re.sub(r"", "", html, flags=re.IGNORECASE) + + # 去掉
      • 内部的

        包裹(防止 contentParser 把 bullet 和文本拆开) + #
      • \n

        content

        \n
      • content
      • + html = re.sub( + r"
      • \s*

        ([\s\S]*?)

        \s*
      • ", + r"
      • \1
      • ", + html, + flags=re.IGNORECASE, + ) + + # 去掉空的
      • + html = re.sub(r"
      • \s*
      • ", "", html, flags=re.IGNORECASE) + + # 去掉空的

        + html = re.sub(r"

        \s*

        ", "", html, flags=re.IGNORECASE) + + # 把

        内的 "- xxx" 列表项拆出来为

        • 格式 + def fix_inline_list(m): + before = m.group(1).strip() + body = m.group(2) + items = re.findall(r"-\s*(.+)", body) + if not items: + return m.group(0) + parts = [f"

          {before}

          "] if before else [] + parts.append("
            ") + for item in items: + parts.append(f"
          • {item.strip()}
          • ") + parts.append("
          ") + return "\n".join(parts) + + html = re.sub( + r"

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

          ", + fix_inline_list, + html, + flags=re.IGNORECASE, + ) + + # 压缩 block 标签之间的空白:\n\n\n + html = re.sub( + r"()\s*\n\s*\n\s*(<(?:h[1-6]|blockquote|ul|ol|table|p|div)[^>]*>)", + r"\1\n\2", + html, + flags=re.IGNORECASE, + ) + + # 合并连续 3+ 换行为 1 个 + html = re.sub(r"\n{3,}", "\n", html) + + return html + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def verify_segments(html: str, chapter_id: str): + """验证 HTML 渲染后不会出现孤立 bullet 和过多空行""" + import re as _re + text = html + text = _re.sub(r"]*>[\s\S]*?

        ", "[TABLE]", text, flags=_re.IGNORECASE) + text = _re.sub(r"

        \s*]*>", "\n\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"

        ", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "• ", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"", "\n", text, flags=_re.IGNORECASE) + text = _re.sub(r"]*>", "[IMG]", text, flags=_re.IGNORECASE) + text = _re.sub(r"<[^>]+>", "", text) + + # 模拟 contentParser 的 split(/\n+/) 行为 + blocks = [b.strip() for b in re.split(r"\n+", text) if b.strip()] + issues = [] + for i, block in enumerate(blocks): + if block == "•" or block == "·": + issues.append(f" 段{i}: 孤立 bullet '{block}'") + + if issues: + print(f" ⚠ {chapter_id} 检测到 {len(issues)} 个格式问题:") + for issue in issues[:5]: + print(issue) + else: + print(f" ✓ {chapter_id} 格式检查通过") + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + verify_segments(html_content, ip_id) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_28_models_html.py b/scripts/reupload_28_models_html.py new file mode 100644 index 00000000..1cd58522 --- /dev/null +++ b/scripts/reupload_28_models_html.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《28套私域商业模式》:Markdown → HTML(含表格/图片/粗体/标题) +1. 遍历 3 篇目录,读取 .md 文件 +2. 把 Markdown 转为 HTML(保留表格、图片、引用、列表、粗体等) +3. 上传本地图片到 OSS,替换为线上 URL +4. 更新 chapters 表的 content 字段 + +用法: + python3 scripts/reupload_28_models_html.py --dry-run # 预览 + python3 scripts/reupload_28_models_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +PARTS = [ + ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), + ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), + ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), +] + +MODEL_NUM_RE = re.compile(r"模式(\d+)") +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"![{alt}]({upload_cache[key]})" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) + html = re.sub(r"\n{3,}", "\n\n", html) + return html.strip() + + +def extract_model_num(filename: str) -> int: + m = MODEL_NUM_RE.search(filename) + return int(m.group(1)) if m else 999 + + +def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: + """返回 [(model_num, section_title, path)]""" + result = [] + for f in part_dir.iterdir(): + if not f.name.endswith(".md") or f.name.startswith("."): + continue + num = extract_model_num(f.name) + title = f.stem + result.append((num, title, f)) + result.sort(key=lambda x: x[0]) + return result + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for dir_name, part_id, chapter_id in PARTS: + part_dir = BOOK_DIR / dir_name + if not part_dir.exists(): + print(f"⚠ 目录不存在: {part_dir}") + continue + + print(f"\n{'='*60}") + print(f"{dir_name}") + print(f"{'='*60}") + + files = collect_md_files(part_dir) + for model_num, title, path in files: + ip_id = f"m28.{model_num:02d}" + print(f"\n 📄 {ip_id} | {title}") + + try: + raw = path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, path, upload_cache, dry_run) + html_content = md_to_html(processed) + + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") + preview = html_content[:200].replace("\n", " ") + print(f" [dry] 预览: {preview}...") + total_ok += 1 + continue + + try: + cur.execute( + "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", + (html_content, word_count, ip_id), + ) + if cur.rowcount == 0: + print(f" ⚠ 未找到 id={ip_id},跳过") + else: + conn.commit() + print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 更新失败: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库/上传图片)") + else: + print(f"图片上传缓存: {len(upload_cache)} 张") + + +if __name__ == "__main__": + main() diff --git a/scripts/reupload_gps_html.py b/scripts/reupload_gps_html.py new file mode 100644 index 00000000..cea25892 --- /dev/null +++ b/scripts/reupload_gps_html.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +重新上传《游戏明星GPS导航》:Markdown → 高质量 HTML(表格/列表/粗体/标题/引用/图片)。 +与 reupload_28_models_html.py 同样使用 python-markdown 做完整转换。 + +用法: + python3 scripts/reupload_gps_html.py --dry-run # 预览 + python3 scripts/reupload_gps_html.py # 执行 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +from pathlib import Path + +import markdown +import requests + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《游戏明星GPS导航》") +API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + +BOOK_STRUCTURE = [ + ("part-gps-0", "序、什么是游戏明星", "chapter-gps-0", "什么是游戏明星", [ + (1, "敬告", "序_什么是游戏明星/敬告.md"), + (2, "誓言", "序_什么是游戏明星/誓言.md"), + (3, "一、什么是游戏明星", "序_什么是游戏明星/一、什么是游戏明星.md"), + (4, "二、为什么要做游戏明星", "序_什么是游戏明星/二、为什么要做游戏明星.md"), + (5, "三、你好,未来的游戏明星", "序_什么是游戏明星/三、方向不对,努力全费...三、你好,未来的游戏明星.md"), + (6, "四、赚回十倍学费的一个小礼物", "序_什么是游戏明星/四、赚回十倍学费的一个小礼物.md"), + (7, "五、卡若与玩值电竞的故事", "序_什么是游戏明星/五、卡若与玩值电竞的故事.md"), + ]), + ("part-gps-1", "第一章、打开粉丝宝库的钥匙", "chapter-gps-1", "打开粉丝宝库的钥匙", [ + (1, "粉丝是如何判断你有没有吸引力的", "第1章_打开粉丝宝库的钥匙/粉丝是如何判断你有没有吸引力的.md"), + (2, "一、我是如何发现这些粉丝的秘籍的", "第1章_打开粉丝宝库的钥匙/一、我是如何发现这些粉丝的秘籍的(打开粉丝宝库的钥匙).md"), + (3, "二、目标粉丝聚集地(你的粉丝在哪里?)", "第1章_打开粉丝宝库的钥匙/二、目标粉丝聚集地(你的粉丝在哪里?).md"), + (4, "三、价值输出载体(找出你与粉丝交流的桥梁)", "第1章_打开粉丝宝库的钥匙/三、价值输出载体(找出你与粉丝交流的桥梁).md"), + (5, "四、不为人知的具体吸粉秘籍(总论)", "第1章_打开粉丝宝库的钥匙/四,不为人知的具体吸粉秘籍...(手把手带你吸粉).md"), + (6, "4.1、斗鱼直播间粉丝自动流入", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.1,斗鱼直播间粉丝自动流入【吸粉秘籍】.md"), + (7, "4.2、原创游戏视频大脑缺口弥补术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.2,原创游戏视频大脑缺口弥补术【吸粉秘籍】.md"), + (8, "4.3、批量渗透玩家微信群", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.3,批量渗透玩家微信群【吸粉秘籍】.md"), + (9, "4.4、朋友圈粉丝自我说服裂变", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.4,朋友圈粉丝自我说服裂变【吸粉秘籍】.md"), + (10, "4.5、淘宝老客户快速转移进入你的领地", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.5、淘宝老客户快速转移进入你的领地【吸粉秘籍】.md"), + (11, "4.6、人性换粉(威力巨大)", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.6,人性换粉(不管你在任何时间,任何场合永远有效,威力巨大).md"), + (12, "QQ空间红包引流术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/QQ空间红包引流术.md"), + (13, "微博问答降维吸粉", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/10,微博问答降维人吸粉.md"), + (14, "一个提醒", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/一个提醒.md"), + (15, "五、在这章你学到的东西,和将要得到的东西", "第1章_打开粉丝宝库的钥匙/五、在这章你学到的东西,和将要得到的东西.md"), + ]), + ("part-gps-2", "第二章、粉丝互动养熟秘籍", "chapter-gps-2", "粉丝互动养熟秘籍", [ + (1, "粉丝对你的信任程度,与你的收入成正比", "第2章_粉丝互动养熟秘籍/粉丝对你的信任程度,以你的收入成正比。.md"), + (2, '让粉丝疯狂追捧的\u201c聊天技巧\u201d在哪里?', '第2章_粉丝互动养熟秘籍/让粉丝疯狂追棒的\u201c聊天技巧\u201d在哪里?.md'), + (3, "一、粉丝信任升级逻辑", "第2章_粉丝互动养熟秘籍/一、粉丝信任升级逻辑.md"), + (4, "二、如何在互动中,快速制造粉丝对你的好印象", "第2章_粉丝互动养熟秘籍/二、如何在互动中,快速制造粉丝对你的好印象.md"), + (5, "三、神奇的信任升级导图", "第2章_粉丝互动养熟秘籍/三、神奇的信任升级导图.md"), + (6, "四、3种立即见效的日常互动模型", "第2章_粉丝互动养熟秘籍/四、3种立既见效的日常互动模型.md"), + (7, "五、3个神秘的互动秘诀", "第2章_粉丝互动养熟秘籍/五、3个神秘的互动秘诀.md"), + (8, "六、4个步骤过滤超级粉丝", "第2章_粉丝互动养熟秘籍/六、4个步骤过滤超级粉丝.md"), + (9, "七、日常规划", "第2章_粉丝互动养熟秘籍/七、日常规划.md"), + ]), + ("part-gps-3", "第三章、粉丝转化系统", "chapter-gps-3", "粉丝转化系统", [ + (1, "一、转化的四个前提", "第3章_粉丝转化系统/一、转化的四个前提.md"), + (2, "二、破解转化障碍", "第3章_粉丝转化系统/二、破解转化障碍.md"), + (3, "三、行之有效的转化秘籍", "第3章_粉丝转化系统/三、行之有效的转化秘籍.md"), + (4, "3.1、钟摆楼:一分钟获得变现的机会", "第3章_粉丝转化系统/3.1 钟摆楼,如何在一分钟之内获得变现的机会.md"), + (5, "3.2、社群价值裂变", "第3章_粉丝转化系统/3.2 激起兴趣的一些具体方法,微信群价值裂变.md"), + (6, "3.3、直播间转化实战", "第3章_粉丝转化系统/3.3 直播间转化实战.md"), + (7, "如何真正地掌握这套转化系统", "第3章_粉丝转化系统/一些想说的话,如何真正地掌握这套转化系统.md"), + ]), + ("part-gps-4", "第四章、超级粉丝", "chapter-gps-4", "超级粉丝", [ + (1, "一、粉丝未来价值的重要性", "第4章_超级粉丝/一、粉丝未来价值的重要性.md"), + (2, "二、多维度升级为超级粉丝的谋略", "第4章_超级粉丝/二、多维度升级为超级粉丝的谋略.md"), + (3, "三、立体放大未来价值", "第4章_超级粉丝/三、立体放大未来价值.md"), + (4, "四、从超级粉丝到合伙人", "第4章_超级粉丝/四、从超级粉丝到合伙人.md"), + ]), + ("part-gps-5", "第五章、提升影响力的几个细节", "chapter-gps-5", "提升影响力的几个细节", [ + (1, "一、当你觉得Hold不住自己野心时的处理方法", "第5章_提升影响力的几个细节/一、当你觉得Hold不住自己野心时的处理方法.md"), + (2, "二、保持你的个性,创造自己的小领地", "第5章_提升影响力的几个细节/二、保持你的个性,创造自己的小领地.md"), + (3, "三、如何让粉丝支持你", "第5章_提升影响力的几个细节/三、如何让粉丝和支持你.md"), + (4, "四、提升影响力前必须注意的细节", "第5章_提升影响力的几个细节/四、提升影响力前必须注意的细节.md"), + (5, "五、潜意识认同", "第5章_提升影响力的几个细节/五、潜意识认同.md"), + (6, "细节一、头像(视觉锤)", "第5章_提升影响力的几个细节/提升影响力的具体细节/一、头像.md"), + (7, "细节二、关联性昵称", "第5章_提升影响力的几个细节/提升影响力的具体细节/二、关联性昵称.md"), + (8, "细节三、个性签名", "第5章_提升影响力的几个细节/提升影响力的具体细节/三、个性签名.md"), + (9, "细节四、定位", "第5章_提升影响力的几个细节/提升影响力的具体细节/四、定位.md"), + (10, "案例:咨询卡若", "第5章_提升影响力的几个细节/案例/咨询卡若 (目前为了拒绝一些没什么问题的用户,你花半小时解答,他花30秒看完).md"), + ]), + ("part-gps-6", "第六章、建造属于自己的游戏明星领地", "chapter-gps-6", "建造属于自己的游戏明星领地", [ + (1, "一、什么是老铁", "第6章_建造属于自己的游戏明星领地/一、什么是老铁.md"), + (2, "二、如何设置领地规则", "第6章_建造属于自己的游戏明星领地/二、如何设置领地规则.md"), + (3, "三、玩值电竞——你的游戏明星大本营", "第6章_建造属于自己的游戏明星领地/三、玩值电竞——你的游戏明星大本营.md"), + (4, "四、领地的商业模式设计", "第6章_建造属于自己的游戏明星领地/四、领地的商业模式设计.md"), + (5, "如何坚持下去", "第6章_建造属于自己的游戏明星领地/如何坚持下去.md"), + ]), + ("part-gps-appendix-a", "附录A、玩值电竞入门指南", "chapter-gps-appendix-a", "玩值电竞入门指南", [ + (1, "玩值电竞入门指南", "附录_玩值电竞入门指南/玩值电竞入门指南.md"), + ]), + ("part-gps-appendix-b", "附录B、干货工具箱", "chapter-gps-appendix-b", "干货工具箱", [ + (1, "吸粉话术模板库", "附录_干货工具箱/吸粉话术模板库.md"), + (2, "游戏明星日常运营SOP", "附录_干货工具箱/游戏明星日常运营SOP.md"), + (3, "游戏明星自检清单", "附录_干货工具箱/游戏明星自检清单.md"), + ]), +] + + +def load_db(): + mig = ROOT / "scripts" / "migrate_2026_sections.py" + spec = importlib.util.spec_from_file_location("_mig_db", mig) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.DB_CONFIG + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: + """替换 md 中的本地图片引用为 OSS URL(保持 markdown 格式)""" + def _replace(m): + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + return m.group(0) + local = (md_path.parent / ref).resolve() + if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: + print(f" ⚠ 图片不存在: {ref}") + return m.group(0) + key = str(local) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"https://dry-run/{local.name}" + print(f" [dry] 图片: {local.name}") + else: + print(f" 上传图片: {local.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(local) + print("✓") + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = ref + return f"![{alt}]({upload_cache[key]})" + return MD_IMAGE_RE.sub(_replace, md_text) + + +def clean_excessive_blank_lines(md_text: str) -> str: + """压缩连续 3+ 空行为 2 空行,确保 markdown 解析正常""" + return re.sub(r"\n{3,}", "\n\n", md_text) + + +def md_to_html(md_text: str) -> str: + """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片/代码块""" + lines = md_text.splitlines() + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + cleaned = "\n".join(lines).strip() + cleaned = clean_excessive_blank_lines(cleaned) + html_out = markdown.markdown( + cleaned, + extensions=["tables", "fenced_code", "nl2br"], + ) + html_out = re.sub(r"\n{3,}", "\n\n", html_out) + return html_out.strip() + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql markdown requests") + + dry_run = "--dry-run" in sys.argv + cfg = load_db() + conn = pymysql.connect(**cfg, autocommit=False) + cur = conn.cursor() + + upload_cache: dict[str, str] = {} + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + print(f"\n 📄 {section_id} | {section_title}") + + try: + raw = md_path.read_text(encoding="utf-8") + except Exception as e: + print(f" ✗ 读取失败: {e}") + total_err += 1 + continue + + processed = process_images(raw, md_path, upload_cache, dry_run) + html_content = md_to_html(processed) + word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) + + if dry_run: + print(f" [dry] HTML={len(html_content)}字符, 纯文字={word_count}") + has_table = " Path | None: + if ref.startswith(("http://", "https://")): + return None + p = (md_path.parent / ref).expanduser().resolve() + if p.is_file() and p.suffix.lower() in IMAGE_EXT: + return p + return None + + +def guess_mime(path: Path) -> str: + return { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + +def upload_image(local: Path) -> str: + import requests + url = f"{API_BASE.rstrip('/')}/api/upload" + mime = guess_mime(local) + with local.open("rb") as f: + r = requests.post(url, files={"file": (local.name, f, mime)}, + data={"folder": "book-images"}, timeout=120) + r.raise_for_status() + j = r.json() + if not j.get("success"): + raise RuntimeError(j.get("error") or j.get("message") or str(j)) + out = j.get("url") or (j.get("data") or {}).get("url") + if not out: + raise RuntimeError("响应无 url: " + str(j)[:300]) + return str(out) + + +def md_to_html(md_path: Path, upload_cache: dict[str, str], dry_run: bool) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + + chunks: list[str] = [] + for line in lines: + if line.strip() == "---": + chunks.append("") + continue + m = LINE_IMAGE_ONLY.match(line) + if m: + alt, ref = m.group(1), m.group(2).strip() + if ref.startswith(("http://", "https://")): + chunks.append(f'

        {html.escape(alt)}

        ') + continue + loc = resolve_image(md_path, ref) + if not loc: + chunks.append(f"

        (图片路径无效:{html.escape(ref)})

        ") + continue + key = str(loc) + if key not in upload_cache: + if dry_run: + upload_cache[key] = f"[dry]{loc.name}" + print(f" [dry] 上传图片: {loc.name}") + else: + print(f" 上传图片: {loc.name} …", end=" ", flush=True) + try: + upload_cache[key] = upload_image(loc) + print("✓", upload_cache[key]) + except Exception as e: + print(f"✗ {e}") + upload_cache[key] = f"[上传失败]{loc.name}" + src = upload_cache[key] + chunks.append(f'

        {html.escape(alt)}

        ') + continue + stripped = line.strip() + if stripped.startswith("##"): + heading = stripped.lstrip("#").strip() + chunks.append(f"

        {html.escape(heading)}

        ") + elif stripped: + chunks.append(f"

        {html.escape(stripped)}

        ") + else: + chunks.append("") + + html_parts: list[str] = [] + for c in chunks: + if c == "": + if html_parts and html_parts[-1] != "": + html_parts.append("") + else: + html_parts.append(c) + return "\n".join(html_parts).strip() + "\n" + + +def has_image_refs(md_path: Path) -> bool: + text = md_path.read_text(encoding="utf-8") + return bool(re.search(r"^\s*!\[", text, re.MULTILINE)) + + +def plain_body(md_path: Path) -> str: + raw = md_path.read_text(encoding="utf-8") + lines = raw.splitlines() + if lines and lines[0].lstrip().startswith("#"): + lines = lines[1:] + out_lines = [] + for line in lines: + if line.strip() == "---": + out_lines.append("") + else: + out_lines.append(line) + body = "\n".join(out_lines) + body = body.replace("**", "") + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip() + "\n" + + +def ensure_book(cur, conn) -> int: + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + print(f"书已存在: book_id={row[0]}") + return row[0] + + cur.execute("SELECT MAX(sort_order) FROM books") + max_sort = cur.fetchone()[0] or 0 + + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, max_sort + 1, "published"), + ) + conn.commit() + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + book_id = cur.fetchone()[0] + print(f"已创建书: book_id={book_id}, key={BOOK_KEY}, title={BOOK_TITLE}") + return book_id + + +def upsert_section(cur, conn, book_id: int, section_id: str, part_id: str, part_title: str, + chapter_id: str, chapter_title: str, section_title: str, + content: str, sort_order: int, price: float): + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + "UPDATE chapters SET book_id=%s, section_title=%s, content=%s, " + "part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, " + "price=%s, sort_order=%s, word_count=%s, status=%s, " + "edition_standard=%s, edition_premium=%s WHERE id=%s", + (book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, sort_order, len(content), "published", + True, False, section_id), + ) + conn.commit() + return "updated" + else: + cur.execute( + "INSERT INTO chapters (id, book_id, section_title, content, " + "part_id, part_title, chapter_id, chapter_title, " + "price, is_free, sort_order, word_count, status, " + "edition_standard, edition_premium) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + (section_id, book_id, section_title, content, + part_id, part_title, chapter_id, chapter_title, + price, False, sort_order, len(content), "published", + True, False), + ) + conn.commit() + return "created" + + +def main(): + try: + import pymysql + except ImportError: + sys.exit("需要: pip install pymysql") + + dry_run = "--dry-run" in sys.argv + price = 1.0 + + cfg = load_db() + conn = pymysql.connect(**cfg) + cur = conn.cursor() + + if not dry_run: + book_id = ensure_book(cur, conn) + else: + book_id = 0 + print("[dry-run] 跳过创建书") + + upload_cache: dict[str, str] = {} + global_sort = 0 + total_ok = 0 + total_err = 0 + + for part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE: + print(f"\n{'='*60}") + print(f"{part_title}") + print(f" part_id={part_id} chapter_id={chapter_id}") + print(f"{'='*60}") + + for seq, section_title, rel_path in sections: + global_sort += 1 + part_num = part_id.replace("part-gps-", "") + section_id = f"gps-{part_num}.{seq}" + md_path = BOOK_DIR / rel_path + + if not md_path.exists(): + print(f" ✗ 文件不存在: {rel_path}") + total_err += 1 + continue + + has_imgs = has_image_refs(md_path) + img_tag = "🖼" if has_imgs else " " + + try: + if has_imgs: + content = md_to_html(md_path, upload_cache, dry_run) + else: + content = plain_body(md_path) + except Exception as e: + print(f" ✗ 读取失败 {rel_path}: {e}") + total_err += 1 + continue + + if dry_run: + print(f" {img_tag} [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + continue + + try: + action = upsert_section( + cur, conn, book_id, section_id, + part_id, part_title, chapter_id, chapter_title, + section_title, content, global_sort, price, + ) + print(f" ✓ [{action}] [{section_id}] sort={global_sort} {section_title[:40]}") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + + +if __name__ == "__main__": + main() diff --git a/scripts/upload_solo_company_book.py b/scripts/upload_solo_company_book.py new file mode 100644 index 00000000..dff67a1c --- /dev/null +++ b/scripts/upload_solo_company_book.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +上传《一人公司——你能掌控的自由人生》到小程序。 + +book_key : solo-company +共 10 章 + 1 术语手册,分 4 个篇。 + +用法: + python3 scripts/upload_solo_company_book.py --dry-run # 预览 + python3 scripts/upload_solo_company_book.py # 执行上传 +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《一人公司》") + +BOOK_KEY = "solo-company" +BOOK_TITLE = "一人公司" +BOOK_SUBTITLE = "你能掌控的自由人生" +BOOK_EMOJI = "🏢" +BOOK_SORT_ORDER = 10 + +# 篇章结构定义 +PARTS = { + "part-solo-1": { + "title": "启程·方向", + "chapters": { + "chapter-solo-1": { + "title": "找准方向,别再瞎忙", + "sections": [1, 2, 3], + } + }, + }, + "part-solo-2": { + "title": "实战·方法", + "chapters": { + "chapter-solo-2": { + "title": "用对方法,高效变现", + "sections": [4, 5, 6], + } + }, + }, + "part-solo-3": { + "title": "进阶·系统", + "chapters": { + "chapter-solo-3": { + "title": "搭建系统,持续增长", + "sections": [7, 8, 9, 10], + } + }, + }, + "part-solo-appendix": { + "title": "附录", + "chapters": { + "ch-solo-appendix": { + "title": "术语手册", + "sections": ["glossary"], + } + }, + }, +} + +# 章节文件映射 +SECTION_FILES = { + 1: "1、你越努力越穷,是因为你连方向都没找对.md", + 2: "2、9个人干出千万业绩:一人公司到底怎么运转的.md", + 3: "3、别再找项目了——你自己就是最值钱的产品.md", + 4: "4、我从不追求原创,但我每篇内容都赚到了钱.md", + 5: "5、30个账号每天自动来客户——你也能复制这套打法.md", + 6: "6、你认识谁,比你会什么值钱10倍.md", + 7: "7、搭一台你睡着了也在赚钱的机器.md", + 8: "8、你赚多少钱,取决于你把自己当成什么人.md", + 9: "9、一天重启你的人生:我用了10年验证的方法.md", + 10: "10、别等准备好再开始——这游戏根本没有终点.md", + "glossary": "术语手册:这本书里的黑话全在这.md", +} + +# 章节标题(去掉序号前缀) +SECTION_TITLES = { + 1: "你越努力越穷,是因为你连方向都没找对", + 2: "9个人干出千万业绩:一人公司到底怎么运转的", + 3: "别再找项目了——你自己就是最值钱的产品", + 4: "我从不追求原创,但我每篇内容都赚到了钱", + 5: "30个账号每天自动来客户——你也能复制这套打法", + 6: "你认识谁,比你会什么值钱10倍", + 7: "搭一台你睡着了也在赚钱的机器", + 8: "你赚多少钱,取决于你把自己当成什么人", + 9: "一天重启你的人生:我用了10年验证的方法", + 10: "别等准备好再开始——这游戏根本没有终点", + "glossary": "术语手册:这本书里的黑话全在这", +} + +DB_CONFIG = { + "host": "56b4c23f6853c.gz.cdb.myqcloud.com", + "port": 14413, + "user": "cdb_outerroot", + "password": "Zhiqun1984", + "database": "soul_miniprogram", +} + + +def md_to_html(md_text: str) -> str: + """将 Markdown 转为美观的 HTML(适配小程序 contentParser)""" + lines = md_text.splitlines() + # 去掉第一行 # 标题 + if lines and lines[0].lstrip().startswith("# "): + lines = lines[1:] + + html_parts: list[str] = [] + i = 0 + + def inline_format(text: str) -> str: + """处理行内格式:粗体、斜体、行内代码、链接""" + # 粗体+斜体 + text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text) + # 粗体 + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + # 斜体 + text = re.sub(r"(?\1", text) + # 行内代码 + text = re.sub(r"`(.+?)`", r"\1", text) + # 链接 + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'
        \1', text) + return text + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # 空行跳过 + if not stripped: + i += 1 + continue + + # 图片 → 跳过(小程序不支持外链图片) + if re.match(r"^!\[.*\]\(.*\)$", stripped): + i += 1 + continue + + # 水平线 + if stripped == "---": + html_parts.append("
        ") + i += 1 + continue + + # h2 + if stripped.startswith("## "): + heading_text = inline_format(stripped[3:].strip()) + html_parts.append(f"

        {heading_text}

        ") + i += 1 + continue + + # h3 + if stripped.startswith("### "): + heading_text = inline_format(stripped[4:].strip()) + html_parts.append(f"

        {heading_text}

        ") + i += 1 + continue + + # 引用块 + if stripped.startswith("> "): + quote_lines = [] + while i < len(lines) and lines[i].strip().startswith("> "): + quote_lines.append(inline_format(lines[i].strip()[2:].strip())) + i += 1 + html_parts.append(f"
        {'
        '.join(quote_lines)}
        ") + continue + + # 待办列表 - [ ] / - [x] + if re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines) and re.match(r"^- \[[ x]\] ", lines[i].strip()): + s = lines[i].strip() + checked = s[3] == "x" + text = inline_format(s[6:].strip()) + check_mark = "✅" if checked else "☐" + items.append(f"
      • {check_mark} {text}
      • ") + i += 1 + html_parts.append(f"
          {''.join(items)}
        ") + continue + + # 有序列表 + if re.match(r"^\d+\.\s+", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^\d+\.\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"
      • {text}
      • ") + i += 1 + # 如果下一行是缩进的续行,合并进当前 li + while i < len(lines) and lines[i].strip() and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("- ") and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">") and lines[i].startswith(" "): + items[-1] = items[-1].replace("", f"
        {inline_format(lines[i].strip())}") + i += 1 + html_parts.append(f"
          {''.join(items)}
        ") + continue + + # 无序列表(含多级缩进 *) + if re.match(r"^[-*]\s+", stripped) and not re.match(r"^- \[[ x]\] ", stripped): + items = [] + while i < len(lines): + s = lines[i].strip() + m = re.match(r"^[-*]\s+(.+)", s) + if not m: + break + text = inline_format(m.group(1)) + items.append(f"
      • {text}
      • ") + i += 1 + # 缩进续行 + while i < len(lines) and lines[i].strip() and not re.match(r"^[-*]\s+", lines[i].strip()) and not re.match(r"^\d+\.\s+", lines[i].strip()) and not lines[i].strip().startswith("#") and not lines[i].strip().startswith(">"): + if lines[i].startswith(" ") or lines[i].startswith("\t"): + sub = lines[i].strip() + sub_m = re.match(r"^[-*]\s+(.+)", sub) + if sub_m: + items[-1] = items[-1].replace("", f"
        • {inline_format(sub_m.group(1))}") + else: + items[-1] = items[-1].replace("", f"
        {inline_format(sub)}") + i += 1 + else: + break + html_parts.append(f"
          {''.join(items)}
        ") + continue + + # 普通段落 + para_lines = [inline_format(stripped)] + i += 1 + html_parts.append(f"

        {para_lines[0]}

        ") + continue + + return "\n".join(html_parts) + + +def find_section_location(section_key) -> tuple[str, str, str, str]: + """根据 section key 找到对应的 part/chapter 信息""" + for part_id, part_info in PARTS.items(): + for ch_id, ch_info in part_info["chapters"].items(): + if section_key in ch_info["sections"]: + return part_id, part_info["title"], ch_id, ch_info["title"] + return "part-solo-1", "启程·方向", "chapter-solo-1", "找准方向,别再瞎忙" + + +def main(): + try: + import pymysql + except ImportError: + print("需要: pip install pymysql", file=sys.stderr) + sys.exit(1) + + dry_run = "--dry-run" in sys.argv + price_free = 0.0 + price_paid = 1.0 + + conn = pymysql.connect(**DB_CONFIG) + cur = conn.cursor() + + # ── Step 1: 确保书籍存在 ── + cur.execute("SELECT id FROM books WHERE book_key = %s", (BOOK_KEY,)) + row = cur.fetchone() + if row: + book_id = row[0] + print(f"📖 书籍已存在: id={book_id}, key={BOOK_KEY}") + if not dry_run: + cur.execute( + "UPDATE books SET title=%s, subtitle=%s, icon_emoji=%s, sort_order=%s, status='published' WHERE id=%s", + (BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER, book_id), + ) + conn.commit() + else: + if dry_run: + print(f"📖 [dry] 将创建书籍: {BOOK_KEY} - {BOOK_TITLE}") + book_id = 999 + else: + cur.execute( + "INSERT INTO books (book_key, title, subtitle, icon_emoji, sort_order, status) VALUES (%s, %s, %s, %s, %s, 'published')", + (BOOK_KEY, BOOK_TITLE, BOOK_SUBTITLE, BOOK_EMOJI, BOOK_SORT_ORDER), + ) + conn.commit() + book_id = cur.lastrowid + print(f"📖 已创建书籍: id={book_id}, key={BOOK_KEY}") + + # ── Step 2: 上传各章节 ── + total_ok = 0 + total_err = 0 + sort_order = 0 + + for section_key in list(range(1, 11)) + ["glossary"]: + filename = SECTION_FILES[section_key] + title = SECTION_TITLES[section_key] + section_id = f"solo-{section_key}" if isinstance(section_key, int) else "solo-glossary" + part_id, part_title, chapter_id, chapter_title = find_section_location(section_key) + + filepath = BOOK_DIR / filename + if not filepath.exists(): + print(f" ⚠ 文件不存在: {filepath}") + total_err += 1 + continue + + raw = filepath.read_text(encoding="utf-8") + html_content = md_to_html(raw) + word_count = len(re.sub(r"<[^>]+>", "", html_content)) + + is_free = section_key in [1, "glossary"] + price = price_free if is_free else price_paid + + if dry_run: + preview = html_content[:80].replace("\n", " ") + print(f" [dry] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字 | {'免费' if is_free else '付费'} | {preview}…") + total_ok += 1 + sort_order += 1 + continue + + try: + cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,)) + existing = cur.fetchone() + if existing: + cur.execute( + """UPDATE chapters SET + book_id=%s, section_title=%s, content=%s, word_count=%s, + part_id=%s, part_title=%s, chapter_id=%s, chapter_title=%s, + price=%s, is_free=%s, sort_order=%s, status='published', + edition_standard=1, edition_premium=0 + WHERE id=%s""", + (book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, section_id), + ) + conn.commit() + print(f" ✓ [updated] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + else: + cur.execute( + """INSERT INTO chapters + (id, book_id, section_title, content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order, status, edition_standard, edition_premium) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published', 1, 0)""", + (section_id, book_id, title, html_content, word_count, + part_id, part_title, chapter_id, chapter_title, + price, is_free, sort_order), + ) + conn.commit() + print(f" ✓ [created] {section_id:16s} | {title[:35]:35s} | {word_count:5d}字") + total_ok += 1 + except Exception as e: + conn.rollback() + print(f" ✗ 失败 {section_id}: {e}") + total_err += 1 + + sort_order += 1 + + conn.close() + print(f"\n{'='*60}") + print(f"完成:成功 {total_ok} | 失败 {total_err}") + if dry_run: + print("(dry-run 模式,未实际写库)") + else: + print("✅ 上传完毕!请在管理端或小程序验证。") + + +if __name__ == "__main__": + main() diff --git a/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 ( +
        +
        + {showImg ? ( + setImgFailed(true)} + /> + ) : ( + {initial} + )} +
        + +
        + ) +} + +export const CkbLeadsPanel = forwardRef( + 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 && ( +
        + {leadsError} + +
        + )} + {!leadsLoading && ( +
        +
        +

        线索总数

        +

        {leadsTotal}

        +
        +
        +

        去重用户

        +

        + {leadsStats.uniqueUsers ?? 0} +

        +
        + {(leadsStats.sourceStats && leadsStats.sourceStats.length > 0 + ? leadsStats.sourceStats.slice(0, 2) + : [] + ).map((s) => ( +
        +

        {s.source}

        +

        {s.cnt}

        +
        + ))} +
        + )} + {!leadsLoading && failedReasonStats.length > 0 && ( + + +
        +

        失败聚合

        + +
        +
        + {failedReasonStats.slice(0, 8).map((s) => ( + + {s.reason} · {s.count} + + ))} +
        +
        +
        + )} +
        +
        +
        + + setLeadsSearchTerm(e.target.value)} + className="pl-9 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500" + /> +
        + {leadsStats.sourceStats && leadsStats.sourceStats.length > 0 && ( + + )} + + + + + + 本页 {leadsRawCount} 条{leadsDeduped > 0 ? ` · 已合并 ${leadsDeduped} 条重复` : ''} + +
        +
        + + + + +
        +
        + {!leadsLoading && leadsRows.length > 0 && ( +

        + 已选 {leadSelectedIds.length} 条 + {leadSelectedIds.length > 0 && ( + + )} +

        + )} + + + {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) => ( + + + 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 ( + + ) + })()} + + +
        + {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() && ( + + )} + {!!r.ckbError && ( +

        + {r.ckbError} +

        + )} +
        +
        + + {r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} + + +
        +

        + {typeof r.retryCount === 'number' ? `第 ${r.retryCount} 次` : '-'} +

        +
        + + +
        +
        +
        +
        + ))} + {leadsRows.length === 0 && ( + + +
        + +

        暂无获客线索

        +

        + {debouncedLeadsSearch.trim() || leadsSourceFilter + ? '当前筛选无匹配记录,可清空后重试' + : '获客计划产生的留资会出现在此列表'} +

        + +
        +
        +
        + )} +
        +
        + {}} + /> +
        + )} +
        +
        + + setShowDetailModal(false)} + userId={selectedUserIdForDetail} + onUserUpdated={() => { + onUserDetailUpdated?.() + void loadLeads() + }} + /> + + + + + {ckbDataDialogTitle} + +
        +
        +                {ckbDataDialogContent || '—'}
        +              
        +
        + + + + +
        +
        +
        + ) + }, +) + +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>({}) 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 性格自动生成。头像内不显示中英文,仅显示人物形象,颜色与站点主题融合。 - - - - - - - - +
        + + + + +
        +

        + 头像文件由 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 (
        -
        - {t} - - {meta.title} - -
        - -
        -
        - {url ? ( - {t} - ) : ( - 未配 - )} -
        -
        - setAvatars((prev) => ({ ...prev, [t]: e.target.value }))} +
        + {showPlaceholder ? ( + + {t} + + ) : ( + {t} setImgBroken((prev) => ({ ...prev, [t]: true }))} + onLoad={(e) => { + const el = e.target as HTMLImageElement + if (el.naturalWidth <= 0) setImgBroken((prev) => ({ ...prev, [t]: true })) + }} /> -
        -
        - -
        - - + )}
        + {t} + + {MBTI_AVATAR_PROFILES[t].nickname} +
        ) })} diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index 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>({}) 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 ( <> onClose()}> - - - + + + 用户详情 {user?.phone && 已绑定手机} @@ -612,254 +645,234 @@ export function UserDetailModal({ 加载中...
        ) : user ? ( -
        - {/* 头部:身份 + 收益(紧凑) */} -
        -
        -
        - {resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? ( - setAvatarBroken(true)} - /> - ) : ( - user.nickname?.charAt(0) || '?' - )} -
        -
        -
        -

        {user.nickname}

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

        - 推荐码 {user.referralCode} -

        - )} -
        -
        - 昵称 -

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

        +
        +
        +

        {user.nickname}

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

        {editPhone || '—'}

        -
        -
        - 微信标识 -

        {editWechatId || '—'}

        -
        -
        - 画像 -

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

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

        累计佣金

        -

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

        -

        推广/分佣入账

        -
        -
        -

        待提现

        -

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

        -

        未打款部分

        -
        -
        -
        -

        账户余额

        - +
        +
        +

        累计佣金

        +

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

        +
        +
        +

        待提现

        +

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

        +
        +
        +
        +

        账户余额

        + +
        +

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

        +
        +
        +

        链接点击

        +

        {user.referralClickCount ?? 0}

        +
        +
        +

        绑定/转化

        +

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

        -

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

        -

        可消费/抵扣

        -
        -
        -

        推荐人数

        -

        {user.referralCount ?? 0}

        -

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

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

        - 用户ID {user.id} -

        -

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

        -

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

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

        + + 编辑资料 +

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

        用户ID {user.id}

        +

        OpenID {user.openId || '—'}

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

        + + 外部资料 · 神射手 +

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

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

        } - {sssError &&

        {sssError}

        } + {user.ckbSyncedAt &&

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

        } + {sssError &&

        {sssError}

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

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

        +

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

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

        + + 购买记录 +

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

        + + 行为统计 +

        +

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

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

        暂无统计数据

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

        + + 行为轨迹 +

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

        详情: {track.target}

        + )} +

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

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

        详情: {track.target}

        - )} -

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

        + )) + ) : ( +
        + +

        暂无旅程记录

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

        + + 入站关系链路 +

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

        首次来自

        -

        +

        +
        +

        首次来自

        +

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

        -
        -

        最近来自

        -

        +

        +

        最近来自

        +

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

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

        +

        +

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

        ) : null} -
        +

        来源点击记录

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

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

        - {v.page ?

        {v.page}

        : null} + {v.page ?

        {v.page}

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

        暂无来源点击记录

        +

        暂无来源点击记录

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

        + + 推荐信息 +

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

        暂无推荐用户

        +

        暂无推荐用户

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

        + + 用户标签 +

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

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

        +

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

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

        {group.category}

        -
        +

        {group.category}

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

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

        -
        +
        +

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

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

        + + 存客宝标签 +

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

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

        +
        + + 正文 HTML

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

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

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

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

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

        +
        + + +
        +
        +

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

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

        {chapter.title}

        + +

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

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

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

        e.stopPropagation()}> {onEditChapter && ( @@ -900,7 +961,7 @@ export function ChapterTree({ )}
        -
        + {expandedChapters.has(chapter.id) &&
        {chapter.sections.map((section) => { const secDragOver = isDragOver('section', section.id) return ( @@ -955,7 +1016,7 @@ export function ChapterTree({ ) : ( ¥{section.price} )} - 点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)} + {clickStatsBadge(section)} · 付款 {(section.payCount ?? 0)} 热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名 {onShowSectionOrders && (
        ) })} -
        +
        }
        ) })} diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 1b9937ff..d50e36bc 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -38,7 +38,6 @@ import { Save, X, RefreshCw, - Link2, Plus, Search, Trophy, @@ -50,45 +49,62 @@ import { ExternalLink, Pencil, Users, - Share2, Webhook, + ArrowUp, + ArrowDown, + ListOrdered, + Link2, } from 'lucide-react' import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client' +import { ckbWorkbenchHref } from '@/lib/ckbWorkbench' import { ChapterTree } from './ChapterTree' import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal' -import { getPersonDetail } from '@/api/ckb' +// getPersonDetail 不再需要(@列表已整合到超级个体) import { apiUrl } from '@/api/client' /** 与 soul-api mergeReadPreviewUI 默认键一致;小程序用 {percent} {price} 占位符替换 */ const READ_PREVIEW_UI_TEMPLATE = JSON.stringify( { - singlePageUnlockTitle: '解锁完整内容', + singlePageUnlockTitle: '解锁全文', singlePagePayButtonText: '支付 ¥{price} 解锁全文', singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。', payTapModalTitle: '解锁说明', payTapModalContent: '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - fullUnlockTitle: '解锁完整内容', - fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明', - fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读', + fullUnlockTitle: '', + fullUnlockDesc: '', + fullLockedProgressText: '', fullPaywallTip: '转发给需要的人,一起学习还能赚佣金', - notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文', - notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金', - shareTipLine: '好友经你分享购买,你可获得约 90% 收益', + notLoginUnlockDesc: '', + notLoginPaywallTip: '转发给需要的人,一起学习还能赚佣金', + shareTipLine: '转发给需要的人,一起学习还能赚佣金', momentsModalTitle: '分享到朋友圈', momentsModalContent: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。', momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——', - timelineTitleSuffix: '(预览{percent}%)', + timelineImageUrl: '', }, null, 2, ) +interface BookItem { + id: number + bookKey: string + title: string + subtitle: string + icon: string + iconEmoji: string + sortOrder: number + status: string + sectionCount?: number +} + interface SectionListItem { id: string mid?: number + bookId?: number title: string price: number isFree?: boolean @@ -228,6 +244,17 @@ function buildTree(sections: SectionListItem[], hotRankMap: Map, export function ContentPage() { const navigate = useNavigate() + const [booksList, setBooksList] = useState([]) + const [currentBookId, setCurrentBookId] = useState(1) + const [, setBooksLoading] = useState(false) + const [showBookModal, setShowBookModal] = useState(false) + const [editingBook, setEditingBook] = useState(null) + const [bookForm, setBookForm] = useState({ title: '', subtitle: '', iconEmoji: '', sortOrder: 0 }) + const [bookSaving, setBookSaving] = useState(false) + const [showBookSortModal, setShowBookSortModal] = useState(false) + const [bookSortDraft, setBookSortDraft] = useState([]) + const [bookSortSaving, setBookSortSaving] = useState(false) + const [sectionsList, setSectionsList] = useState([]) const [loading, setLoading] = useState(true) const [expandedParts, setExpandedParts] = useState([]) @@ -274,11 +301,15 @@ export function ContentPage() { const [batchMoveTargetPartId, setBatchMoveTargetPartId] = useState('') const [batchMoveTargetChapterId, setBatchMoveTargetChapterId] = useState('') const [isMoving, setIsMoving] = useState(false) + const [isPushingCkb, setIsPushingCkb] = useState(false) const [newPartTitle, setNewPartTitle] = useState('') const [isSavingPart, setIsSavingPart] = useState(false) const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null) const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false) + const [clickStatsModal, setClickStatsModal] = useState<{ sectionId: string; title: string; totalClicks: number; users: { userId: string; nickname: string; avatar: string; count: number; lastClick: string }[] } | null>(null) + const [clickStatsLoading, setClickStatsLoading] = useState(false) const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false) + const [showDisplayRules, setShowDisplayRules] = useState(false) const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 }) const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false) const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false) @@ -290,6 +321,8 @@ export function ContentPage() { const [previewPercent, setPreviewPercent] = useState(20) const [previewPercentLoading, setPreviewPercentLoading] = useState(false) const [previewPercentSaving, setPreviewPercentSaving] = useState(false) + const [fullbookThreshold, setFullbookThreshold] = useState(3) + const [fullbookThresholdSaving, setFullbookThresholdSaving] = useState(false) const [readPreviewUiJson, setReadPreviewUiJson] = useState(READ_PREVIEW_UI_TEMPLATE) const [readPreviewUiLoading, setReadPreviewUiLoading] = useState(false) const [readPreviewUiSaving, setReadPreviewUiSaving] = useState(false) @@ -313,18 +346,22 @@ export function ContentPage() { appId: '', appSecret: '', pagePath: '', + passPhone: false, + phoneParamName: 'phone', }) const [linkTagSaving, setLinkTagSaving] = useState(false) const [personModalOpen, setPersonModalOpen] = useState(false) - const [editingPerson, setEditingPerson] = useState(null) + const [editingPerson, _setEditingPerson] = useState(null) + void _setEditingPerson const [personToDelete, setPersonToDelete] = useState(null) - /** 超级个体用户 id → 飞书群 Webhook(与 /api/db/vip-members 一致,供 @列表展示与编辑) */ - const [vipWebhookByUserId, setVipWebhookByUserId] = useState>({}) + const [_vipWebhookByUserId, setVipWebhookByUserId] = useState>({}) + void _vipWebhookByUserId const [personVipWebhookTarget, setPersonVipWebhookTarget] = useState<{ userId: string; name: string } | null>(null) const [personVipWebhookInput, setPersonVipWebhookInput] = useState('') const [personVipWebhookSaving, setPersonVipWebhookSaving] = useState(false) // CKB 获客统计(按人物 token 聚合) - const [ckbLeadCounts, setCkbLeadCounts] = useState>({}) + const [_ckbLeadCounts, setCkbLeadCounts] = useState>({}) + void _ckbLeadCounts const [ckbLeadDetailOpen, setCkbLeadDetailOpen] = useState(false) const [ckbLeadDetailToken, setCkbLeadDetailToken] = useState('') const [ckbLeadDetailName, setCkbLeadDetailName] = useState('') @@ -332,7 +369,8 @@ export function ContentPage() { const [ckbLeadTotal, setCkbLeadTotal] = useState(0) const [ckbLeadPage, setCkbLeadPage] = useState(1) const [ckbLeadLoading, setCkbLeadLoading] = useState(false) - const [ckbWebhookUrl, setCkbWebhookUrl] = useState('') + const [_ckbWebhookUrl, setCkbWebhookUrl] = useState('') + void _ckbWebhookUrl const richEditorRef = useRef(null) /** 章节富文本:图片 / 视频 / 附件统一走 /api/upload,按 folder 区分存储目录 */ @@ -382,14 +420,30 @@ export function ContentPage() { const rankingTotalPages = Math.max(1, Math.ceil(rankedSectionsList.length / RANKING_PAGE_SIZE)) const rankingPageSections = rankedSectionsList.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE) - const loadList = async () => { + const loadBooks = async () => { + setBooksLoading(true) + try { + const data = await get<{ success?: boolean; books?: BookItem[] }>('/api/db/books', { cache: 'no-store' as RequestCache }) + if (data?.books) setBooksList(data.books) + } catch (e) { + console.error(e) + } finally { + setBooksLoading(false) + } + } + + const loadList = async (bookId?: number) => { setLoading(true) try { + const bid = bookId ?? currentBookId const data = await get<{ success?: boolean; sections?: SectionListItem[] }>( - '/api/db/book?action=list', + `/api/db/book?action=list&bookId=${bid}`, { cache: 'no-store' as RequestCache }, ) - setSectionsList(Array.isArray(data?.sections) ? data.sections : []) + const sections = Array.isArray(data?.sections) ? data.sections : [] + setSectionsList(sections) + const partIds = [...new Set(sections.map(s => s.partId).filter(Boolean))] as string[] + if (partIds.length > 0) setExpandedParts(partIds) } catch (e) { console.error(e) setSectionsList([]) @@ -398,6 +452,90 @@ export function ContentPage() { } } + const handleSwitchBook = (bookId: number) => { + setCurrentBookId(bookId) + setEditingSection(null) + loadList(bookId) + } + + const handleSaveBook = async () => { + if (!bookForm.title.trim()) { toast.error('书名不能为空'); return } + setBookSaving(true) + try { + if (editingBook) { + await put('/api/db/books', { id: editingBook.id, ...bookForm }) + toast.success('已更新') + } else { + await post('/api/db/books', bookForm) + toast.success('已创建') + } + setShowBookModal(false) + setEditingBook(null) + loadBooks() + } catch (e) { + toast.error('保存失败') + } finally { + setBookSaving(false) + } + } + + const handleDeleteBook = async (id: number) => { + if (id === 1) { toast.error('主书不可删除'); return } + try { + const res = await del<{ success?: boolean; error?: string }>(`/api/db/books?id=${id}`) + if (res?.success) { + toast.success('已删除') + loadBooks() + if (currentBookId === id) handleSwitchBook(1) + } else { + toast.error(res?.error || '删除失败') + } + } catch { toast.error('删除失败') } + } + + const openBookSortModal = () => { + setBookSortDraft([...booksList].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0) || a.id - b.id)) + setShowBookSortModal(true) + } + + const moveBookInDraft = (index: number, dir: -1 | 1) => { + const nextIndex = index + dir + if (nextIndex < 0 || nextIndex >= bookSortDraft.length) return + const next = [...bookSortDraft] + const [item] = next.splice(index, 1) + next.splice(nextIndex, 0, item) + setBookSortDraft(next.map((b, i) => ({ ...b, sortOrder: i + 1 }))) + } + + const handleSaveBookSort = async () => { + if (bookSortDraft.length === 0) return + setBookSortSaving(true) + try { + await put('/api/db/books?action=reorder', { + items: bookSortDraft.map((b, i) => ({ id: b.id, sortOrder: i + 1 })), + }) + toast.success('书籍排序已保存') + setShowBookSortModal(false) + await loadBooks() + } catch (e) { + console.error(e) + toast.error('保存排序失败') + } finally { + setBookSortSaving(false) + } + } + + const openBookModal = (book?: BookItem) => { + if (book) { + setEditingBook(book) + setBookForm({ title: book.title, subtitle: book.subtitle, iconEmoji: book.iconEmoji, sortOrder: book.sortOrder }) + } else { + setEditingBook(null) + setBookForm({ title: '', subtitle: '', iconEmoji: '', sortOrder: booksList.length + 1 }) + } + setShowBookModal(true) + } + const loadPartBadges = async () => { try { const res = await get<{ success?: boolean; data?: Record | Array<{ configKey?: string; configValue?: unknown }> }>( @@ -446,6 +584,7 @@ export function ContentPage() { } useEffect(() => { + loadBooks() loadList() loadRanking() loadPartBadges() @@ -647,43 +786,7 @@ export function ContentPage() { } catch { /* ignore */ } }, []) - const handlePersonPin = useCallback( - async (p: PersonItem, nextPinned: boolean) => { - const pid = (p.personId || p.id || '').trim() - if (!pid) { - toast.error('缺少 personId') - return - } - if (nextPinned && !(p.userId || '').trim()) { - toast.info('未绑定会员时,小程序仍显示 @ 名称,头像可能为默认图') - } - try { - const res = await put<{ success?: boolean; error?: string }>('/api/db/persons/pin', { - personId: pid, - isPinned: nextPinned, - }) - if (!res?.success) { - toast.error(res?.error || '置顶失败') - return - } - toast.success(nextPinned ? '已设为小程序首页置顶(全局仅一条)' : '已取消置顶') - await loadPersons() - } catch (e) { - toast.error(e instanceof Error ? e.message : '操作失败') - } - }, - [loadPersons], - ) - - const openPersonVipWebhook = useCallback((p: PersonItem) => { - const uid = (p.userId || '').trim() - if (!uid) { - toast.info('未绑定会员的人物无法配置:飞书群 Webhook 按「超级个体用户」维度保存,请先为该人物绑定会员用户。') - return - } - setPersonVipWebhookTarget({ userId: uid, name: p.name }) - setPersonVipWebhookInput((vipWebhookByUserId[uid] || '').trim()) - }, [vipWebhookByUserId]) + // handlePersonPin / openPersonVipWebhook 已迁移到 SuperIndividualTab const handleSavePersonVipWebhook = useCallback(async () => { if (!personVipWebhookTarget) return @@ -730,6 +833,8 @@ export function ContentPage() { type: string appId?: string pagePath?: string + passPhone?: boolean + phoneParamName?: string hasAppSecret?: boolean }[] }>('/api/db/link-tags') @@ -742,6 +847,8 @@ export function ContentPage() { type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal', appId: t.appId || '', pagePath: t.pagePath || '', + passPhone: !!t.passPhone, + phoneParamName: (t.phoneParamName || 'phone').trim() || 'phone', hasAppSecret: !!t.hasAppSecret, })), ) @@ -811,6 +918,8 @@ export function ContentPage() { type: string appId?: string pagePath?: string + passPhone?: boolean + phoneParamName?: string hasAppSecret?: boolean }[] total?: number @@ -829,6 +938,8 @@ export function ContentPage() { type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal', appId: t.appId || '', pagePath: t.pagePath || '', + passPhone: !!t.passPhone, + phoneParamName: (t.phoneParamName || 'phone').trim() || 'phone', hasAppSecret: !!t.hasAppSecret, })), ) @@ -908,6 +1019,31 @@ export function ContentPage() { } catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) } } + const loadFullbookThreshold = useCallback(async () => { + try { + const data = await get<{ success?: boolean; data?: number }>( + '/api/db/config/full?key=fullbook_show_threshold', + { cache: 'no-store' as RequestCache }, + ) + const d = data && (data as { data?: number }).data + if (typeof d === 'number' && d > 0) setFullbookThreshold(d) + } catch { /* keep default */ } + }, []) + + const handleSaveFullbookThreshold = async () => { + if (fullbookThreshold < 1) { toast.error('阈值至少为 1'); return } + setFullbookThresholdSaving(true) + try { + const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { + key: 'fullbook_show_threshold', + value: fullbookThreshold, + description: '购买多少小节后显示「加入读书会」按钮(默认3,与第二次付款露出规则一致可调)', + }) + if (res && (res as { success?: boolean }).success !== false) toast.success('阈值已保存') + else toast.error('保存失败: ' + ((res as { error?: string }).error || '')) + } catch { toast.error('保存失败') } finally { setFullbookThresholdSaving(false) } + } + const loadReadPreviewUi = useCallback(async () => { setReadPreviewUiLoading(true) try { @@ -955,13 +1091,14 @@ export function ContentPage() { useEffect(() => { loadPinnedSections() loadPreviewPercent() + loadFullbookThreshold() loadReadPreviewUi() loadPersons() loadLinkTags() loadCkbLeadCounts() loadLinkedMps() loadCkbWebhookUrl() - }, [loadPinnedSections, loadPreviewPercent, loadReadPreviewUi, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl]) + }, [loadPinnedSections, loadPreviewPercent, loadFullbookThreshold, loadReadPreviewUi, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl]) useEffect(() => { loadLinkTagList() @@ -984,6 +1121,28 @@ export function ContentPage() { } } + const handleShowClickStats = async (section: { id: string; title: string }) => { + setClickStatsModal({ sectionId: section.id, title: section.title, totalClicks: 0, users: [] }) + setClickStatsLoading(true) + try { + const data = await get<{ success?: boolean; totalClicks?: number; users?: { userId: string; nickname: string; avatar: string; count: number; lastClick: string }[] }>( + `/api/admin/chapters/${encodeURIComponent(section.id)}/click-stats`, + ) + if (data?.success) { + setClickStatsModal({ + sectionId: section.id, + title: section.title, + totalClicks: data.totalClicks ?? 0, + users: data.users ?? [], + }) + } + } catch (e) { + console.error(e) + } finally { + setClickStatsLoading(false) + } + } + const handleReadSection = async (section: Section & { filePath?: string }) => { setIsLoadingContent(true) try { @@ -1133,6 +1292,7 @@ export function ContentPage() { '/api/db/book', { id: newSection.id, + bookId: currentBookId, title: newSection.title, price: newSection.isFree ? 0 : newSection.price, content: newSection.content || '', @@ -1391,6 +1551,7 @@ export function ContentPage() { const sectionId = `part-placeholder-${Date.now()}` const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { id: sectionId, + bookId: currentBookId, title: '占位节(可编辑)', price: 0, content: '', @@ -1416,6 +1577,30 @@ export function ContentPage() { } } + const handlePushToCkb = async () => { + if (selectedSectionIds.length === 0) { + toast.error('请先勾选要推送的章节') + return + } + setIsPushingCkb(true) + try { + const res = await post<{ success?: boolean; error?: string; message?: string; results?: Array<{ id: string; title: string; status: string }> }>( + '/api/admin/ckb/push-content', + { chapterIds: selectedSectionIds }, + ) + if (res?.success) { + toast.success(res.message || `已推送 ${selectedSectionIds.length} 条内容到存客宝`) + setSelectedSectionIds([]) + } else { + toast.error(res?.error || '推送失败') + } + } catch (err) { + toast.error('推送失败: ' + (err instanceof Error ? err.message : String(err))) + } finally { + setIsPushingCkb(false) + } + } + const handleBatchMoveToTarget = async () => { if (selectedSectionIds.length === 0) { toast.error('请先勾选要移动的章节') @@ -1560,24 +1745,124 @@ export function ContentPage() { const currentPart = tree.find((p) => p.id === newSection.partId) const chaptersForPart = currentPart?.chapters ?? [] + const currentBook = booksList.find((b) => b.id === currentBookId) + return (
        + {/* 书籍选择栏 */} +
        + {booksList.map((b) => ( + + ))} + + + {currentBook && ( + + )} +
        +
        -

        内容管理

        +

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

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

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

        内容显示规则

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