From 6d11fb295d612b2cbf9a0d0d7089dcfd6ea9519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=8B=A5?= Date: Mon, 6 Apr 2026 15:59:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=89=E7=AB=AF=E6=94=B9=E5=8A=A8=E5=B9=B6=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=87=AD=E8=AF=81=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。 Made-with: Cursor --- miniprogram/app.js | 52 +- miniprogram/pages/chapters/chapters.js | 8 +- miniprogram/pages/gift-pay/detail.js | 15 +- miniprogram/pages/index/index.js | 395 +++- miniprogram/pages/index/index.wxml | 36 +- miniprogram/pages/index/index.wxss | 285 +++ miniprogram/pages/match/match.js | 13 +- miniprogram/pages/read/read.js | 23 +- miniprogram/pages/read/read.wxml | 12 +- miniprogram/pages/vip/vip.js | 15 +- miniprogram/pages/wallet/wallet.js | 5 +- miniprogram/project.config.json | 6 +- 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 ++ soul-admin/package.json | 1 + soul-admin/pnpm-lock.yaml | 29 + soul-admin/src/App.tsx | 83 +- soul-admin/src/api/client.ts | 4 +- .../modules/leads/CkbLeadsPanel.tsx | 1036 ++++++++++ .../modules/mbti/MbtiAvatarsManager.tsx | 140 +- .../modules/user/UserDetailModal.tsx | 2 +- soul-admin/src/lib/mbtiAvatarPrompts.ts | 35 +- soul-admin/src/pages/content/ContentPage.tsx | 550 +----- .../src/pages/dashboard/DashboardPage.tsx | 148 +- .../pages/distribution/DistributionPage.tsx | 276 +-- .../pages/find-partner/FindPartnerPage.tsx | 158 +- .../pages/find-partner/tabs/MatchPoolTab.tsx | 79 + .../find-partner/tabs/MentorBookingTab.tsx | 58 +- .../src/pages/find-partner/tabs/MentorTab.tsx | 2 +- .../src/pages/settings/SettingsPage.tsx | 7 + .../src/pages/users/HomeEntryConfigTab.tsx | 576 ++++++ .../src/pages/users/SuperIndividualTab.tsx | 727 +++++++ soul-admin/src/pages/users/UsersPage.tsx | 1666 +---------------- soul-admin/tsconfig.tsbuildinfo | 2 +- soul-admin/vite.config.ts | 2 + soul-api/internal/database/database.go | 5 + .../internal/handler/admin_mbti_avatars.go | 57 + soul-api/internal/handler/admin_rfm.go | 41 + soul-api/internal/handler/db.go | 34 +- soul-api/internal/handler/db_person.go | 37 +- soul-api/internal/handler/gift_pay.go | 21 + soul-api/internal/handler/miniprogram.go | 454 ++++- .../handler/miniprogram_super_stats.go | 39 + soul-api/internal/handler/order_webhook.go | 40 +- .../internal/handler/referral_commission.go | 3 + soul-api/internal/handler/user.go | 21 +- .../internal/handler/vip_members_admin.go | 92 +- soul-api/internal/handler/ws.go | 19 +- soul-api/internal/model/person.go | 19 +- soul-api/internal/router/router.go | 6 + soul-api/internal/wechat/miniprogram.go | 6 + soul-api/master.py | 171 +- soul-api/文档索引.md | 38 +- .../ENFJ.svg | 4 + .../ENFP.svg | 4 + .../ENTJ.svg | 4 + .../ENTP.svg | 4 + .../ESFJ.svg | 4 + .../ESFP.svg | 4 + .../ESTJ.svg | 4 + .../ESTP.svg | 4 + .../INFJ.svg | 4 + .../INFP.svg | 4 + .../INTJ.svg | 4 + .../INTP.svg | 4 + .../ISFJ.svg | 4 + .../ISFP.svg | 4 + .../ISTJ.svg | 4 + .../ISTP.svg | 4 + .../README.txt | 6 + .../ENFJ.svg | 4 + .../ENFP.svg | 4 + .../ENTJ.svg | 4 + .../ENTP.svg | 4 + .../ESFJ.svg | 4 + .../ESFP.svg | 4 + .../ESTJ.svg | 4 + .../ESTP.svg | 4 + .../INFJ.svg | 4 + .../INFP.svg | 4 + .../INTJ.svg | 4 + .../INTP.svg | 4 + .../ISFJ.svg | 4 + .../ISFP.svg | 4 + .../ISTJ.svg | 4 + .../ISTP.svg | 4 + .../README.txt | 6 + 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 | 22 +- 开发文档/6、后端/README.md | 27 +- 开发文档/8、部署/README.md | 29 +- 开发文档/索引.md | 201 +- 126 files changed, 5789 insertions(+), 2791 deletions(-) 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 soul-admin/src/components/modules/leads/CkbLeadsPanel.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/internal/handler/miniprogram_super_stats.go create mode 100644 static/mbti-avatars-16personalities-female/ENFJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ENFP.svg create mode 100644 static/mbti-avatars-16personalities-female/ENTJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ENTP.svg create mode 100644 static/mbti-avatars-16personalities-female/ESFJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ESFP.svg create mode 100644 static/mbti-avatars-16personalities-female/ESTJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ESTP.svg create mode 100644 static/mbti-avatars-16personalities-female/INFJ.svg create mode 100644 static/mbti-avatars-16personalities-female/INFP.svg create mode 100644 static/mbti-avatars-16personalities-female/INTJ.svg create mode 100644 static/mbti-avatars-16personalities-female/INTP.svg create mode 100644 static/mbti-avatars-16personalities-female/ISFJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ISFP.svg create mode 100644 static/mbti-avatars-16personalities-female/ISTJ.svg create mode 100644 static/mbti-avatars-16personalities-female/ISTP.svg create mode 100644 static/mbti-avatars-16personalities-female/README.txt create mode 100644 static/mbti-avatars-16personalities-male/ENFJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ENFP.svg create mode 100644 static/mbti-avatars-16personalities-male/ENTJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ENTP.svg create mode 100644 static/mbti-avatars-16personalities-male/ESFJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ESFP.svg create mode 100644 static/mbti-avatars-16personalities-male/ESTJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ESTP.svg create mode 100644 static/mbti-avatars-16personalities-male/INFJ.svg create mode 100644 static/mbti-avatars-16personalities-male/INFP.svg create mode 100644 static/mbti-avatars-16personalities-male/INTJ.svg create mode 100644 static/mbti-avatars-16personalities-male/INTP.svg create mode 100644 static/mbti-avatars-16personalities-male/ISFJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ISFP.svg create mode 100644 static/mbti-avatars-16personalities-male/ISTJ.svg create mode 100644 static/mbti-avatars-16personalities-male/ISTP.svg create mode 100644 static/mbti-avatars-16personalities-male/README.txt 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/miniprogram/app.js b/miniprogram/app.js index 0e08b6ea..1773762b 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -10,9 +10,9 @@ const mpPagePopups = require('./utils/mpPagePopups.js') const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' const DEFAULT_MCH_ID = '1318592501' const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' -// baseUrl 手动切换(注释方式): -// const API_BASE_URL = 'http://localhost:9090' -const API_BASE_URL = 'https://soulapi.quwanzhi.com' +// baseUrl 手动切换(本地端口以 soul-api/.env 的 PORT 为准,当前仓库多为 8080;发版前改回线上) +const API_BASE_URL = 'http://127.0.0.1:8080' +// const API_BASE_URL = 'https://soulapi.quwanzhi.com' // const API_BASE_URL = 'https://souldev.quwanzhi.com' const CONFIG_CACHE_KEY = 'mpConfigCacheV1' @@ -548,6 +548,8 @@ App({ const userId = this.globalData.userInfo.id const base = (this.globalData.baseUrl || '').replace(/\/$/, '') if (!base) return + // 本机 API 无稳定 WSS 时跳过,减少控制台报错与无效重连 + if (/^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?$/i.test(base)) return const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram' if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer) @@ -1164,14 +1166,54 @@ App({ } }, + /** 是否为微信小程序支付可用的 openid */ + looksLikeWxOpenId(s) { + const x = String(s || '').trim() + if (x.length < 24 || x.length > 64) return false + if (x.indexOf('user_') === 0) return false + if (x.charAt(0) !== 'o') return false + return /^o[a-zA-Z0-9_-]+$/.test(x) + }, + + /** 从缓存 / userInfo 取合法微信 openid */ + resolvePayOpenId() { + const u = this.globalData.userInfo || {} + const pick = (v) => { + const s = String(v || '').trim() + return this.looksLikeWxOpenId(s) ? s : '' + } + return pick(this.globalData.openId) || pick(wx.getStorageSync('openId')) || pick(u.openId || u.open_id) + }, + + /** 支付前确保拿到真实微信 openid */ + async ensurePayOpenId() { + let oid = this.resolvePayOpenId() + if (oid) { + this.globalData.openId = oid + try { wx.setStorageSync('openId', oid) } catch (_) {} + return oid + } + try { await this.getOpenId() } catch (_) {} + oid = this.resolvePayOpenId() + if (oid) { + this.globalData.openId = oid + try { wx.setStorageSync('openId', oid) } catch (_) {} + return oid + } + return '' + }, + // 获取openId (支付必需) async getOpenId() { if (!this.ensureFullAppForAuth()) { return null } - // 先检查缓存 + // 缓存中如果存的是 userId 等非法值,先清掉 const cachedOpenId = wx.getStorageSync('openId') - if (cachedOpenId) { + if (cachedOpenId && !this.looksLikeWxOpenId(cachedOpenId)) { + try { wx.removeStorageSync('openId') } catch (_) {} + this.globalData.openId = null + } else if (cachedOpenId && this.looksLikeWxOpenId(cachedOpenId)) { this.globalData.openId = cachedOpenId return cachedOpenId } diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js index 25730185..cfe5145e 100644 --- a/miniprogram/pages/chapters/chapters.js +++ b/miniprogram/pages/chapters/chapters.js @@ -180,7 +180,6 @@ Page({ }) } const ch = chMap.get(cid) - const isPremium = r.editionPremium === true || r.edition_premium === true || r.edition_premium === 1 || r.edition_premium === '1' ch.sections.push({ id: r.id, mid: r.mid ?? r.MID ?? 0, @@ -191,7 +190,7 @@ Page({ newBadgeText: this._normalizeBadgeText( r.newBadgeText || r.new_badge_text || r.sectionBadgeText || r.section_badge_text || r.badgeText || r.badge_text ), - isPremium + isPremium: false }) }) const chapters = Array.from(chMap.values()) @@ -327,10 +326,9 @@ Page({ wx.navigateTo({ url: `/pages/read/read?${q}` }) }, - // 检查是否已购买 - hasPurchased(sectionId, isPremium) { + hasPurchased(sectionId) { if (this.data.isVip) return true - if (!isPremium && this.data.hasFullBook) return true + if (this.data.hasFullBook) return true return this.data.purchasedSections.includes(sectionId) }, diff --git a/miniprogram/pages/gift-pay/detail.js b/miniprogram/pages/gift-pay/detail.js index 0e6bc19a..9f25bf77 100644 --- a/miniprogram/pages/gift-pay/detail.js +++ b/miniprogram/pages/gift-pay/detail.js @@ -125,14 +125,15 @@ Page({ wx.showToast({ title: '请先登录后再支付', icon: 'none' }) return } - let openId = app.globalData.openId || wx.getStorageSync('openId') + wx.showLoading({ title: '获取支付凭证...', mask: true }) + const openId = await app.ensurePayOpenId() + wx.hideLoading() if (!openId) { - wx.showLoading({ title: '获取支付凭证...', mask: true }) - openId = await app.getOpenId() - wx.hideLoading() - } - if (!openId) { - wx.showToast({ title: '请先登录', icon: 'none' }) + wx.showModal({ + title: '无法拉起支付', + content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', + showCancel: false, + }) return } let { requestSn, sectionId, detail, giftQuantity, isCreateMode } = this.data diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 9726ecb5..9b299492 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -9,7 +9,18 @@ const { trackClick } = require('../../utils/trackClick') const { cleanSingleLineField } = require('../../utils/contentParser') const { navigateMpPath } = require('../../utils/mpNavigate.js') const { isSafeImageSrc } = require('../../utils/imageUrl.js') -const { submitCkbLead } = require('../../utils/soulBridge') +const { + submitCkbLead, + getReferralCodeForPay, + requestWxJsapiPayment, + syncOrderStatusQuery, +} = require('../../utils/soulBridge') + +const DEFAULT_TIP_GIFTS = [ + { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '', micOrder: 1, iconScale: 1, micFeatured: true }, + { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '', micOrder: 0, iconScale: 1, micFeatured: false }, + { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '', micOrder: 2, iconScale: 1, micFeatured: false }, +] /** 置顶人物无头像时的占位图 */ const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png' @@ -28,6 +39,102 @@ function superAvatarLetter(displayName) { return /[\u4e00-\u9fff]/.test(ch) ? ch : '会' } +function parseTimeToMinutes(t) { + const m = String(t || '').trim().match(/^(\d{1,2}):(\d{2})$/) + if (!m) return null + const h = parseInt(m[1], 10) + const min = parseInt(m[2], 10) + if (h < 0 || h > 23 || min < 0 || min > 59) return null + return h * 60 + min +} + +/** 是否在直播上麦时段(支持跨午夜 end < start) */ +function isNowInMicWindow(startStr, endStr) { + const a = parseTimeToMinutes(startStr) + const b = parseTimeToMinutes(endStr) + if (a == null || b == null) return false + const now = new Date() + const cur = now.getHours() * 60 + now.getMinutes() + if (a <= b) return cur >= a && cur < b + return cur >= a || cur < b +} + +function normCfgBool(v) { + return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true' +} + +function normalizeTipGifts(raw) { + const base = DEFAULT_TIP_GIFTS + if (!Array.isArray(raw) || raw.length === 0) return base.map((g) => ({ ...g })) + return raw.map((row, i) => { + const d = base[Math.min(i, base.length - 1)] + const id = String(row.id != null ? row.id : d.id).trim() || d.id + const name = String(row.name != null ? row.name : d.name).trim() || d.name + let price = parseFloat(row.price) + if (Number.isNaN(price) || price <= 0) price = d.price + const emoji = String(row.emoji != null ? row.emoji : d.emoji).trim() || d.emoji + const icon = String(row.icon != null ? row.icon : '').trim() + let micOrder = row.micOrder != null ? parseInt(String(row.micOrder), 10) : NaN + if (Number.isNaN(micOrder)) micOrder = 999 + let iconScale = parseFloat(row.iconScale) + if (Number.isNaN(iconScale) || iconScale <= 0) iconScale = 1 + const micFeatured = + row.micFeatured === true || + row.micFeatured === 1 || + row.micFeatured === '1' || + String(row.micFeatured).toLowerCase() === 'true' + return { id, name, price, emoji, icon, micOrder, iconScale, micFeatured } + }) +} + +function sortGiftsForMic(arr) { + return [...arr].sort((a, b) => { + const oa = a.micOrder != null && !Number.isNaN(Number(a.micOrder)) ? Number(a.micOrder) : 999 + const ob = b.micOrder != null && !Number.isNaN(Number(b.micOrder)) ? Number(b.micOrder) : 999 + if (oa !== ob) return oa - ob + return 0 + }) +} + +/** + * 上麦横排:按礼物 micOrder 排序,最多 3 档;默认档优先 micFeatured,否则中间位 + * micEmojiRpx:默认档再乘 liveMicSchedule.micCenterEmojiScale + */ +function layoutMicHorizontalSlots(gifts, liveMicSchedule) { + const fallback = DEFAULT_TIP_GIFTS.map((g) => ({ ...g })) + let arr = Array.isArray(gifts) && gifts.length ? gifts : fallback + arr = sortGiftsForMic(arr) + const slotsRaw = arr.slice(0, 3) + let defaultIndex = slotsRaw.length >= 2 ? 1 : 0 + const fi = slotsRaw.findIndex( + (g) => g.micFeatured === true || g.micFeatured === 1 || g.micFeatured === '1' || String(g.micFeatured).toLowerCase() === 'true' + ) + if (fi >= 0) defaultIndex = fi + const def = slotsRaw[defaultIndex] + const defaultId = def && def.id ? def.id : '' + let centerScale = 1.55 + if (liveMicSchedule && liveMicSchedule.micCenterEmojiScale != null) { + const v = parseFloat(String(liveMicSchedule.micCenterEmojiScale)) + if (!Number.isNaN(v) && v >= 1) centerScale = Math.min(v, 2.4) + } + const n = slotsRaw.length + /** 几何中间列:3 档为 index 1;2 档为 index 1;1 档为 0 */ + const centerIdx = n <= 1 ? 0 : Math.floor(n / 2) + const baseRpx = 44 + const slots = slotsRaw.map((g, idx) => { + const per = g.iconScale != null && Number(g.iconScale) > 0 ? Number(g.iconScale) : 1 + const isDef = g.id === defaultId + const isCenterSlot = idx === centerIdx + let rpx = Math.round(baseRpx * per * (isDef ? centerScale : 1)) + if (isCenterSlot) rpx = Math.round(rpx * 1.22) + rpx = Math.max(32, Math.min(110, rpx)) + const iconStr = String(g.icon || '').trim() + const micShowIcon = !!(iconStr && isSafeImageSrc(iconStr)) + return { ...g, micEmojiRpx: rpx, micShowIcon, micIsCenterSlot: isCenterSlot } + }) + return { slots, defaultId } +} + Page({ data: { // 系统信息 @@ -99,6 +206,42 @@ Page({ /** 后台 @列表置顶人物:有则右上角展示绑定用户头像 + @名称,点击走 ckb/lead */ homePinnedPerson: null, + + /** 首页入口:打赏 + 直播时段上麦(mpUi.homePage.linkKaruoReward / liveMicSchedule) */ + linkKaruoRewardEnabled: false, + linkKaruoRewardUi: { + entryButtonText: '支持一下', + modalTitle: '打赏支持', + modalSubtitle: '感谢支持,任选礼物与数量', + payButtonText: '立即支付', + leadLinkText: '留下联系方式', + afterPayToast: '支付成功,感谢支持!', + }, + liveMicSchedule: { + enabled: false, + micStart: '18:00', + micEnd: '21:00', + micEmoji: '🎤', + micButtonText: '申请上麦', + micModalTitle: '申请上麦', + micModalContent: '', + micAfterSubmitToast: '支付成功,感谢支持!', + micCenterEmojiScale: 1.55, + }, + homeMicEligible: false, + tipGifts: [], + showTipModal: false, + tipHomeMicMode: false, + tipModalTitle: '', + tipModalSubtitle: '', + selectedTipGiftId: '', + tipQty: 1, + tipLineTotal: '0.00', + /** 上麦弹窗横排礼物(与后台 gifts 顺序一致) */ + micHorizontalSlots: [], + micDefaultGiftId: '', + /** 右上角唯一入口:由置顶超级个体决定 */ + headerCornerAction: { show: false, kind: '', label: '', emoji: '', avatar: '' }, }, onLoad(options) { @@ -121,9 +264,13 @@ Page({ this.initData() }, - onShow() { + async onShow() { console.log('[Index] onShow 触发') + try { + await app.getConfig(true) + } catch (_) {} this.setData({ auditMode: app.globalData.auditMode || false }) + this._refreshHomeEntryFlags() void this.loadHomePinnedPerson() // 设置TabBar选中状态 @@ -357,13 +504,15 @@ Page({ patch.mpUiLinkKaruoText = '' patch.mpUiLinkKaruoDisplay = DEFAULT_KARUO_LINK_AVATAR } - this.setData(patch) - try { - wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' }) - } catch (_) {} + this.setData(patch, () => { + try { + wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' }) + } catch (_) {} + this._syncHeaderCornerAction() + }) }, - /** 拉取后台置顶 @人物,合并到首页右上角「链接」区 */ + /** 拉取后台置顶 @人物,合并到首页右上角「链接」区;同时拿到该人的 homeEntryConfig */ async loadHomePinnedPerson() { try { const res = await app.request({ url: '/api/miniprogram/ckb/pinned-person', silent: true }) @@ -376,6 +525,7 @@ Page({ token: String(res.data.token).trim(), name, avatar: av, + homeEntryConfig: res.data.homeEntryConfig || null, }, }) } else { @@ -385,21 +535,28 @@ Page({ console.log('[Index] pinned-person:', e) this.setData({ homePinnedPerson: null }) } + const pin = (this.data.homePinnedPerson || {}) + const override = {} + if (pin.homeEntryConfig) { + if (pin.homeEntryConfig.linkKaruoReward) override.linkKaruoReward = pin.homeEntryConfig.linkKaruoReward + if (pin.homeEntryConfig.liveMicSchedule) override.liveMicSchedule = pin.homeEntryConfig.liveMicSchedule + } this._applyHomeMpUi() + this._refreshHomeEntryFlags(Object.keys(override).length ? override : undefined) }, async loadFeatureConfig() { try { - const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean' - if (!hasCachedFeatures) { - const res = await app.getConfig() - const features = (res && res.features) || (res && res.data && res.data.features) || {} + await app.getConfig(true) + const res = app.globalData.configCache + if (res) { + const features = res.features || (res.data && res.data.features) || {} const searchEnabled = features.searchEnabled !== false if (!app.globalData.features) app.globalData.features = {} app.globalData.features.searchEnabled = searchEnabled if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled - const mp = (res && res.mpConfig) || {} + const mp = res.mpConfig || {} app.globalData.auditMode = !!mp.auditMode } await app.getAuditMode() @@ -409,6 +566,7 @@ Page({ auditMode: app.globalData.auditMode || false }) this._applyHomeMpUi() + this._refreshHomeEntryFlags() } catch (e) { try { await app.getAuditMode() @@ -418,10 +576,218 @@ Page({ auditMode: app.globalData.auditMode || false }) this._applyHomeMpUi() + this._refreshHomeEntryFlags() } await this.loadHomePinnedPerson() }, + _refreshHomeEntryFlags() { + const audit = !!app.globalData.auditMode + // 优先读置顶超级个体的 per-person 配置,回退到全局 mpUi.homePage + const personCfg = (this.data.homePinnedPerson && this.data.homePinnedPerson.homeEntryConfig) || null + const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {} + const rw = (personCfg && personCfg.linkKaruoReward) || h.linkKaruoReward || {} + const schReal = (personCfg && personCfg.liveMicSchedule) || h.liveMicSchedule || {} + const rewardEnabled = normCfgBool(rw.enabled) + const micEnabled = normCfgBool(schReal.enabled) + const inWindow = isNowInMicWindow(schReal.micStart, schReal.micEnd) + const homeMicEligible = !audit && micEnabled && inWindow + const gifts = normalizeTipGifts(Array.isArray(rw.gifts) && rw.gifts.length ? rw.gifts : null) + const tipGifts = gifts.length ? gifts : DEFAULT_TIP_GIFTS.map((g) => ({ ...g })) + this.setData({ + linkKaruoRewardEnabled: rewardEnabled, + linkKaruoRewardUi: { + entryButtonText: String(rw.entryButtonText || '支持一下').trim() || '支持一下', + modalTitle: String(rw.modalTitle || '打赏支持').trim() || '打赏支持', + modalSubtitle: String(rw.modalSubtitle || '感谢支持,任选礼物与数量').trim() || '感谢支持,任选礼物与数量', + payButtonText: String(rw.payButtonText || '立即支付').trim() || '立即支付', + leadLinkText: String(rw.leadLinkText || '留下联系方式').trim() || '留下联系方式', + afterPayToast: String(rw.afterPayToast || '支付成功,感谢支持!').trim() || '支付成功,感谢支持!', + }, + liveMicSchedule: { + enabled: micEnabled, + micStart: String(schReal.micStart || '18:00').trim() || '18:00', + micEnd: String(schReal.micEnd || '21:00').trim() || '21:00', + micEmoji: String(schReal.micEmoji || '🎤').trim() || '🎤', + micButtonText: String(schReal.micButtonText || '申请上麦').trim() || '申请上麦', + micModalTitle: String(schReal.micModalTitle || '申请上麦').trim() || '申请上麦', + micModalContent: String( + schReal.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。', + ).trim(), + micAfterSubmitToast: String(schReal.micAfterSubmitToast || '支付成功,感谢支持!').trim() || '支付成功,感谢支持!', + micCenterEmojiScale: (() => { + const v = parseFloat(String(schReal.micCenterEmojiScale != null ? schReal.micCenterEmojiScale : '1.55')) + if (Number.isNaN(v) || v < 1) return 1.55 + return Math.min(v, 2.4) + })(), + }, + homeMicEligible, + tipGifts, + }) + this._syncHeaderCornerAction() + }, + + /** 右上角单一入口:上麦时段 > 置顶超级个体 > 打赏 */ + _syncHeaderCornerAction() { + if (app.globalData.auditMode) { + this.setData({ headerCornerAction: { show: false, kind: '', label: '', emoji: '', avatar: '' } }) + return + } + const mic = !!this.data.homeMicEligible + const pinned = !!(this.data.homePinnedPerson && this.data.homePinnedPerson.token) + const reward = !!this.data.linkKaruoRewardEnabled + const sch = this.data.liveMicSchedule || {} + let show = false, kind = '', label = '', emoji = '', avatar = '' + if (mic) { + show = true; kind = 'mic' + label = String(sch.micButtonText || '申请上麦').trim() || '申请上麦' + emoji = String(sch.micEmoji || '🎤').trim() || '🎤' + } else if (pinned) { + show = true; kind = 'ckb' + label = String(this.data.mpUiLinkKaruoText || '').trim() || '链接卡若' + avatar = this.data.mpUiLinkKaruoDisplay || DEFAULT_KARUO_LINK_AVATAR + } else if (reward) { + show = true; kind = 'reward' + label = String(sch.micButtonText || '申请上麦').trim() || '申请上麦' + emoji = String(sch.micEmoji || '🎤').trim() || '🎤' + } + this.setData({ headerCornerAction: { show, kind, label, emoji, avatar } }) + }, + + /** 右上角统一点击 */ + onHeaderCornerTap() { + const a = this.data.headerCornerAction + if (!a || !a.show) return + if (a.kind === 'ckb') { + this.onLinkKaruo() + return + } + this._openHorizontalGiftModal(a.kind === 'mic' ? 'live_mic' : 'home_reward') + }, + + /** 打开横排礼物弹窗(上麦/打赏统一入口) */ + _openHorizontalGiftModal(tipSource) { + if (app.globalData.auditMode) return + const label = tipSource === 'live_mic' ? '申请上麦' : '打赏支持' + trackClick('home', tipSource === 'live_mic' ? 'live_mic_request' : 'btn_click', label) + if (!app.globalData.isLoggedIn) { + wx.showModal({ + title: '提示', + content: `请先登录后再${label}`, + confirmText: '去登录', + success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }, + }) + return + } + const sch = this.data.liveMicSchedule + const gifts = this.data.tipGifts || [] + const { slots, defaultId } = layoutMicHorizontalSlots(gifts, sch) + const title = tipSource === 'live_mic' + ? String(sch.micModalTitle || '申请上麦').trim() + : String(this.data.linkKaruoRewardUi.modalTitle || '打赏支持').trim() + const sub = tipSource === 'live_mic' + ? String(sch.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。').trim() + : String(this.data.linkKaruoRewardUi.modalSubtitle || '感谢支持,点选礼物直接支付').trim() + this.setData({ + showTipModal: true, + tipHomeMicMode: tipSource === 'live_mic', + tipModalTitle: title, + tipModalSubtitle: sub, + micHorizontalSlots: slots, + micDefaultGiftId: defaultId, + selectedTipGiftId: defaultId, + tipQty: 1, + }) + }, + + closeTipModal() { + this.setData({ showTipModal: false, micHorizontalSlots: [], micDefaultGiftId: '' }) + }, + + stopTipModalBubble() {}, + + /** 横排礼物卡片点击 → 直接调起微信支付 */ + onHorizontalTipPay(e) { + const id = e.currentTarget.dataset.id + if (!id) return + const src = this.data.tipHomeMicMode ? 'live_mic' : 'home_reward' + this._executeLinkKaruoTipPay(src, id, 1) + }, + + async _executeLinkKaruoTipPay(tipSource, giftId, qty) { + const gid = String(giftId || '').trim() + const q = Math.max(1, parseInt(String(qty), 10) || 1) + if (!gid) { + wx.showToast({ title: '请选择礼物', icon: 'none' }) + return + } + if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + const openId = await app.ensurePayOpenId() + if (!openId) { + wx.showModal({ + title: '无法拉起支付', + content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', + showCancel: false, + }) + return + } + const g = (this.data.tipGifts || []).find((x) => String(x.id) === gid) + const amount = g && Number(g.price) > 0 ? Math.round(Number(g.price) * q * 100) / 100 : 0 + if (amount <= 0) { + wx.showToast({ title: '礼物价格无效', icon: 'none' }) + return + } + wx.showLoading({ title: '下单中...', mask: true }) + try { + const referralCode = getReferralCodeForPay(app) || undefined + const res = await app.request({ + url: '/api/miniprogram/pay', + method: 'POST', + silent: true, + data: { + openId, + productType: 'link_karuo_tip', + productId: tipSource === 'live_mic' ? `live_mic|${gid}|${q}` : `${gid}|${q}`, + amount, + description: tipSource === 'live_mic' ? '上麦礼遇' : '首页打赏', + userId: app.globalData.userInfo.id, + referralCode, + tipSource: tipSource === 'live_mic' ? 'live_mic' : 'home_reward', + }, + }) + wx.hideLoading() + if (!res || !res.success || !res.data || !res.data.payParams) { + const errText = (res && (res.error || res.message)) || '下单失败' + wx.showToast({ title: String(errText).slice(0, 36), icon: 'none' }) + return + } + await requestWxJsapiPayment(res.data.payParams) + const sn = res.data.orderSn + if (sn) { + try { + await syncOrderStatusQuery(app, sn) + } catch (_) {} + } + const toast = + tipSource === 'live_mic' + ? this.data.liveMicSchedule.micAfterSubmitToast || '支付成功' + : this.data.linkKaruoRewardUi.afterPayToast || '支付成功' + wx.showToast({ title: toast, icon: 'success' }) + this.closeTipModal() + } catch (e) { + wx.hideLoading() + const msg = (e && e.errMsg) || (e && e.message) || '支付失败' + if (String(msg).indexOf('cancel') >= 0) { + wx.showToast({ title: '已取消支付', icon: 'none' }) + return + } + wx.showToast({ title: String(msg).slice(0, 28), icon: 'none' }) + } + }, + // 跳转到搜索页 goToSearch() { if (!this.data.searchEnabled) return @@ -496,6 +862,11 @@ Page({ // 下拉刷新(等待各异步加载完成后再结束) async onPullDownRefresh() { + try { + await app.getConfig(true) + } catch (_) {} + this.setData({ auditMode: app.globalData.auditMode || false }) + this._refreshHomeEntryFlags() await Promise.all([ this.loadBookData(), this.loadFeaturedAndLatest(), diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 7b594ce7..9256256f 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -16,10 +16,12 @@ {{mpUiLogoSubtitle}} - - - - {{mpUiLinkKaruoText}} + + + + + {{headerCornerAction.emoji}} + {{headerCornerAction.label}} @@ -161,6 +163,32 @@ + + + + + {{tipModalTitle}} + × + + {{tipModalSubtitle}} + + + 默认 + + {{item.emoji}} + {{item.name}} + ¥{{item.price}} + + + + + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 9278596c..6e0d346d 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -962,6 +962,291 @@ margin-left: 16rpx; } +/* ===== 首页打赏 / 上麦弹层 ===== */ +.mic-entry-emoji { + font-size: 36rpx; + line-height: 1; +} + +.tip-modal-mask { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.65); + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + box-sizing: border-box; +} + +.tip-modal-panel { + width: 100%; + max-width: 620rpx; + background: #1c1c1e; + border-radius: 24rpx; + border: 2rpx solid rgba(0, 206, 209, 0.25); + padding: 36rpx 32rpx 28rpx; + box-sizing: border-box; +} + +/* 申请上麦:更宽横排三档 */ +.tip-modal-panel--mic { + max-width: 710rpx; + padding: 28rpx 24rpx 32rpx; + border-color: rgba(0, 206, 209, 0.35); + box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.45); +} + +.mic-modal-head { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: 16rpx; + padding-right: 4rpx; +} + +.mic-modal-title { + flex: 1; + font-size: 34rpx; + font-weight: 700; + color: #ffffff; + text-align: left; + padding-right: 16rpx; +} + +.mic-modal-x { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.75); + font-size: 40rpx; + line-height: 52rpx; + text-align: center; + font-weight: 300; +} + +.mic-modal-desc { + display: block; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.55); + line-height: 1.5; + margin-bottom: 28rpx; + text-align: left; +} + +.mic-gift-row-h { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: space-between; + gap: 12rpx; +} + +.mic-gift-card { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 28rpx 12rpx 20rpx; + border-radius: 20rpx; + background: rgba(255, 255, 255, 0.06); + border: 2rpx solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; + transform-origin: center bottom; +} + +/* 中间一列整体更大(与左右对比) */ +.mic-gift-card--mid { + flex: 1.12; + padding-top: 32rpx; + padding-bottom: 24rpx; + transform: scale(1.07); + z-index: 2; +} + +.mic-gift-card--default { + border-color: rgba(0, 206, 209, 0.7); + background: rgba(0, 206, 209, 0.1); + box-shadow: 0 0 28rpx rgba(0, 206, 209, 0.22); +} + +.mic-gift-default-tag { + position: absolute; + top: 10rpx; + right: 10rpx; + font-size: 18rpx; + font-weight: 700; + color: #0a1628; + padding: 4rpx 10rpx; + border-radius: 8rpx; + background: linear-gradient(135deg, #00ced1 0%, #ffd700 100%); +} + +.mic-gift-emoji { + line-height: 1.15; + margin-bottom: 10rpx; +} + +.mic-gift-icon-img { + display: block; + margin-bottom: 10rpx; + flex-shrink: 0; +} + +.mic-gift-name { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.92); + text-align: center; + margin-bottom: 8rpx; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mic-gift-price { + font-size: 28rpx; + font-weight: 700; + color: #38bdac; + margin-bottom: 0; +} + +.mic-gift-card--mid .mic-gift-name { + font-size: 26rpx; +} + +.mic-gift-card--mid .mic-gift-price { + font-size: 30rpx; +} + +.tip-modal-title { + display: block; + font-size: 34rpx; + font-weight: 700; + color: #ffffff; + text-align: center; + margin-bottom: 12rpx; +} + +.tip-modal-sub { + display: block; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.55); + text-align: center; + margin-bottom: 28rpx; + line-height: 1.45; +} + +.tip-gift-list { + display: flex; + flex-direction: column; + gap: 16rpx; + margin-bottom: 24rpx; +} + +.tip-gift-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22rpx 24rpx; + border-radius: 16rpx; + background: rgba(255, 255, 255, 0.06); + border: 2rpx solid rgba(255, 255, 255, 0.08); +} + +.tip-gift-row-on { + border-color: rgba(0, 206, 209, 0.55); + background: rgba(0, 206, 209, 0.12); +} + +.tip-gift-left { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.92); +} + +.tip-gift-price { + font-size: 28rpx; + color: #38bdac; + font-weight: 600; +} + +.tip-qty-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16rpx; +} + +.tip-qty-label { + font-size: 26rpx; + color: rgba(255, 255, 255, 0.65); +} + +.tip-qty-ctrl { + display: flex; + align-items: center; + gap: 24rpx; +} + +.tip-qty-btn { + width: 56rpx; + height: 56rpx; + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 32rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.tip-qty-num { + font-size: 30rpx; + color: #fff; + min-width: 48rpx; + text-align: center; +} + +.tip-total-row { + font-size: 28rpx; + color: #38bdac; + text-align: right; + margin-bottom: 20rpx; +} + +.tip-pay-btn { + width: 100% !important; + background: linear-gradient(135deg, #00ced1 0%, #20b2aa 100%) !important; + color: #000 !important; + font-weight: 700; + font-size: 30rpx; + border-radius: 16rpx !important; + margin-bottom: 20rpx; +} + +.tip-lead-link { + text-align: center; + font-size: 26rpx; + color: rgba(0, 206, 209, 0.9); + padding: 12rpx 0 8rpx; + text-decoration: underline; +} + +.tip-modal-close { + text-align: center; + font-size: 26rpx; + color: rgba(255, 255, 255, 0.45); + padding-top: 8rpx; +} + /* ===== 底部留白 ===== */ .bottom-space { height: 40rpx; diff --git a/miniprogram/pages/match/match.js b/miniprogram/pages/match/match.js index b8a83d40..61c5665c 100644 --- a/miniprogram/pages/match/match.js +++ b/miniprogram/pages/match/match.js @@ -704,14 +704,13 @@ Page({ this.setData({ showUnlockModal: false }) try { - // 获取openId - let openId = app.globalData.openId || wx.getStorageSync('openId') + const openId = await app.ensurePayOpenId() if (!openId) { - openId = await app.getOpenId() - } - - if (!openId) { - wx.showToast({ title: '请先登录', icon: 'none' }) + wx.showModal({ + title: '无法拉起支付', + content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', + showCancel: false, + }) return } diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 4ac823d5..55691acf 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -33,12 +33,12 @@ const READ_UI_DEFAULTS = { payTapModalTitle: '解锁说明', payTapModalContent: '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - fullUnlockTitle: '解锁完整内容', + fullUnlockTitle: '加入365读书会', fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明', fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读', - fullPaywallTip: '转发给需要的人,一起学习还能赚佣金', + fullPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文', - notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金', + notLoginPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', shareTipLine: '好友经你分享购买,你可获得约 90% 收益', momentsModalTitle: '分享到朋友圈', momentsModalContent: @@ -191,6 +191,7 @@ Page({ hasFullBook: false, canAccess: false, // 保留兼容性,从 accessState 派生 purchasedCount: 0, + fullbookShowThreshold: 2, // 阅读进度 readingProgress: 0, @@ -491,9 +492,14 @@ Page({ try { const config = await accessManager.fetchLatestConfig() + const purchasedSections = app.globalData.purchasedSections || [] + const rawThreshold = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold + const threshold = (rawThreshold != null && Number(rawThreshold) > 0) ? Number(rawThreshold) : 2 this.setData({ sectionPrice: config.prices?.section ?? 1, - fullBookPrice: config.prices?.fullbook ?? 9.9 + fullBookPrice: config.prices?.fullbook ?? 9.9, + purchasedCount: purchasedSections.length, + fullbookShowThreshold: threshold, }) // 统一:先拉章节数据,用 isFree/price===0 判断免费 @@ -1749,6 +1755,7 @@ Page({ app.globalData.userInfo = userInfo wx.setStorageSync('userInfo', userInfo) + this.setData({ purchasedCount: (res.data.purchasedSections || []).length }) console.log('[Pay] ✅ 购买状态已刷新:', { hasFullBook: res.data.hasFullBook, purchasedCount: res.data.purchasedSections.length @@ -1756,7 +1763,6 @@ Page({ } } catch (e) { console.error('[Pay] 刷新购买状态失败:', e) - // 刷新失败时不影响用户体验,只是记录日志 } }, @@ -2062,9 +2068,14 @@ Page({ try { const config = await accessManager.fetchLatestConfig() + const purchasedSections2 = app.globalData.purchasedSections || [] + const rawTh2 = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold + const th2 = (rawTh2 != null && Number(rawTh2) > 0) ? Number(rawTh2) : 2 this.setData({ sectionPrice: config.prices?.section ?? 1, - fullBookPrice: config.prices?.fullbook ?? 9.9 + fullBookPrice: config.prices?.fullbook ?? 9.9, + purchasedCount: purchasedSections2.length, + fullbookShowThreshold: th2, }) // 重新拉取章节,用 isFree/price 判断免费 diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index 87037a7d..3902ba7f 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -158,7 +158,7 @@ - {{readUi.fullUnlockTitle || '解锁完整内容'}} + {{readUi.fullUnlockTitle || '加入365读书会'}} {{readBeforeLoginHint}} {{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}} @@ -170,7 +170,7 @@ - {{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}} + {{readUi.notLoginPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}} @@ -245,12 +245,12 @@ {{readUi.singlePageExpandedHint || '预览页不能直接付款,务必先点底栏「前往小程序」。'}} {{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}} - {{readUi.fullPaywallTip || '转发给需要的人,一起学习还能赚佣金'}} + {{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}} - {{readUi.fullUnlockTitle || '解锁完整内容'}} + {{readUi.fullUnlockTitle || '加入365读书会'}} {{readUi.fullUnlockDesc || '可先上滑阅读预览'}} {{readUi.fullLockedProgressText}} 已阅读约 {{effectivePreviewPercent}}% ,购买后继续阅读 @@ -259,7 +259,7 @@ 购买本章 ¥{{section && section.price != null ? section.price : sectionPrice}} - + 解锁全部 {{totalSections}} 章 @@ -271,7 +271,7 @@ 审核中,暂不支持购买 - {{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}} + {{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}} diff --git a/miniprogram/pages/vip/vip.js b/miniprogram/pages/vip/vip.js index a964b7db..7e9f4874 100644 --- a/miniprogram/pages/vip/vip.js +++ b/miniprogram/pages/vip/vip.js @@ -68,15 +68,13 @@ Page({ async handlePurchase() { trackClick('vip', 'btn_click', '开通VIP') let userId = app.globalData.userInfo?.id - let openId = app.globalData.openId || app.globalData.userInfo?.open_id - if (!userId || !openId) { + if (!userId) { wx.showLoading({ title: '登录中...', mask: true }) try { await app.login() userId = app.globalData.userInfo?.id - openId = app.globalData.openId || app.globalData.userInfo?.open_id wx.hideLoading() - if (!userId || !openId) { + if (!userId) { wx.showToast({ title: '登录失败,请重试', icon: 'none' }) return } @@ -86,6 +84,15 @@ Page({ return } } + const openId = await app.ensurePayOpenId() + if (!openId) { + wx.showModal({ + title: '无法拉起支付', + content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', + showCancel: false, + }) + return + } // VIP 购买成功后再跳转资料页:购买前不拦截 this.setData({ purchasing: true }) const amount = this.data.price diff --git a/miniprogram/pages/wallet/wallet.js b/miniprogram/pages/wallet/wallet.js index 98b01d8b..c1c90d45 100644 --- a/miniprogram/pages/wallet/wallet.js +++ b/miniprogram/pages/wallet/wallet.js @@ -83,10 +83,7 @@ Page({ const userId = app.globalData.userInfo.id const amount = this.data.selectedAmount - let openId = app.globalData.openId - if (!openId) { - openId = await app.getOpenId() - } + const openId = await app.ensurePayOpenId() if (!openId) { wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none', duration: 2500 }) return diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json index b2ddab5b..632fbfb6 100644 --- a/miniprogram/project.config.json +++ b/miniprogram/project.config.json @@ -4,7 +4,7 @@ "description": "卡若创业派对 - 来自派对房的真实商业故事", "appid": "wxb8bbb2b10dec74aa", "setting": { - "urlCheck": true, + "urlCheck": false, "es6": true, "enhance": true, "postcss": true, @@ -54,5 +54,7 @@ "packOptions": { "ignore": [], "include": [] - } + }, + "projectname": "miniprogram", + "libVersion": "3.13.2" } \ No newline at end of file diff --git a/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/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/modules/leads/CkbLeadsPanel.tsx b/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx new file mode 100644 index 00000000..13a27f9b --- /dev/null +++ b/soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx @@ -0,0 +1,1036 @@ +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}

+
+
+

去重用户数(按 userId)

+

+ {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..29ad1dfe 100644 --- a/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx +++ b/soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx @@ -2,16 +2,23 @@ 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 { Save, RefreshCw, WandSparkles } from 'lucide-react' import { MBTI_TYPES_ORDERED, MBTI_AVATAR_PROFILES, buildMbtiSvgAvatarDataUrl, - type MbtiType, } from '@/lib/mbtiAvatarPrompts' +import { apiUrl } from '@/api/client' + +/** 网格预览:相对路径走 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) +} export function MbtiAvatarsManager() { const [avatars, setAvatars] = useState>({}) @@ -53,12 +60,6 @@ export function MbtiAvatarsManager() { } } - const generateOne = (type: MbtiType) => { - const dataUrl = buildMbtiSvgAvatarDataUrl(type) - setAvatars((prev) => ({ ...prev, [type]: dataUrl })) - toast.success(`${type} 已生成`) - } - const generateAll = () => { setGenerating(true) try { @@ -73,6 +74,15 @@ export function MbtiAvatarsManager() { } } + const fill16pAvatars = (gender: 'male' | 'female') => { + const next = { ...avatars } + MBTI_TYPES_ORDERED.forEach((t) => { + next[t] = apiUrl(`/static/mbti-avatars-png-${gender}/${t}.png`) + }) + setAvatars(next) + toast.success(`已填充 16P ${gender === 'male' ? '男版' : '女版'}(记得点保存)`) + } + if (loading) { return (
@@ -84,86 +94,48 @@ export function MbtiAvatarsManager() { return (
- - - - - MBTI 头像库 - - - 采用人物化风格,按 MBTI 性格自动生成。头像内不显示中英文,仅显示人物形象,颜色与站点主题融合。 - - - - - - - - +
+ + + + + + +
+

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

-
+
{MBTI_TYPES_ORDERED.map((t) => { const url = avatars[t] ?? '' const meta = MBTI_AVATAR_PROFILES[t] return ( -
-
- {t} - - {meta.title} - -
- -
-
- {url ? ( - {t} - ) : ( - 未配 - )} -
-
- setAvatars((prev) => ({ ...prev, [t]: e.target.value }))} - /> -
-
- -
- - +
+
+ {url ? {t} { (e.target as HTMLImageElement).style.display = 'none' }} /> : 未配}
+ {t} + {meta.nickname}
) })} diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index 4f7cc163..899d555b 100644 --- a/soul-admin/src/components/modules/user/UserDetailModal.tsx +++ b/soul-admin/src/components/modules/user/UserDetailModal.tsx @@ -332,7 +332,7 @@ export function UserDetailModal({ } catch { 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(userId)}&status=paid&pageSize=50`, ) if (ordersData?.success && ordersData.orders) setPurchaseList(ordersData.orders) else setPurchaseList([]) diff --git a/soul-admin/src/lib/mbtiAvatarPrompts.ts b/soul-admin/src/lib/mbtiAvatarPrompts.ts index cbc5d3ca..e7306431 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,22 +36,22 @@ 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) { diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 1b9937ff..dc352a53 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +// useNavigate removed (@列表已迁移) import toast from '@/utils/toast' import { Card, @@ -38,7 +38,6 @@ import { Save, X, RefreshCw, - Link2, Plus, Search, Trophy, @@ -50,13 +49,12 @@ import { ExternalLink, Pencil, Users, - Share2, Webhook, } from 'lucide-react' import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client' 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} 占位符替换 */ @@ -68,12 +66,12 @@ const READ_PREVIEW_UI_TEMPLATE = JSON.stringify( payTapModalTitle: '解锁说明', payTapModalContent: '全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。', - fullUnlockTitle: '解锁完整内容', + fullUnlockTitle: '加入365读书会', fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明', fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读', - fullPaywallTip: '转发给需要的人,一起学习还能赚佣金', + fullPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文', - notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金', + notLoginPaywallTip: '分享给好友,好友购买你可获得约 90% 收益', shareTipLine: '好友经你分享购买,你可获得约 90% 收益', momentsModalTitle: '分享到朋友圈', momentsModalContent: @@ -227,7 +225,6 @@ function buildTree(sections: SectionListItem[], hotRankMap: Map, } export function ContentPage() { - const navigate = useNavigate() const [sectionsList, setSectionsList] = useState([]) const [loading, setLoading] = useState(true) const [expandedParts, setExpandedParts] = useState([]) @@ -290,6 +287,8 @@ export function ContentPage() { const [previewPercent, setPreviewPercent] = useState(20) const [previewPercentLoading, setPreviewPercentLoading] = useState(false) const [previewPercentSaving, setPreviewPercentSaving] = useState(false) + const [fullbookThreshold, setFullbookThreshold] = useState(2) + const [fullbookThresholdSaving, setFullbookThresholdSaving] = useState(false) const [readPreviewUiJson, setReadPreviewUiJson] = useState(READ_PREVIEW_UI_TEMPLATE) const [readPreviewUiLoading, setReadPreviewUiLoading] = useState(false) const [readPreviewUiSaving, setReadPreviewUiSaving] = useState(false) @@ -316,15 +315,17 @@ export function ContentPage() { }) 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 +333,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 区分存储目录 */ @@ -647,43 +649,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 @@ -908,6 +874,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: '购买多少小节后显示「解锁全书」按钮(默认2)', + }) + 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 +946,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() @@ -1635,20 +1627,6 @@ export function ContentPage() {
-
- -
- -
-
@@ -1663,31 +1641,6 @@ export function ContentPage() {
-
- -
- - -
-
-
- -
- -
-
@@ -2349,43 +2283,6 @@ export function ContentPage() {
-
- -
- - -
-
内容搜索 - - - @列表 - + {/* @列表已整合到 用户管理 → 超级个体;保留 persons state 供编辑器 @ 提示 */} 链接标签 @@ -2673,6 +2567,29 @@ export function ContentPage() { 小程序未付费用户默认显示文章前 {previewPercent}% 内容;章节「预览%」可单独覆盖
+
+
+ + setFullbookThreshold(Math.max(1, Number(e.target.value) || 2))} + /> + 小节 +
+ + 用户已购买 ≥ {fullbookThreshold} 小节后,阅读页付费墙显示「解锁全书」按钮 +

@@ -2825,338 +2742,9 @@ export function ContentPage() { - - {/* AI列表(@人物) */} - - - - @ - AI列表 — @列表(编辑器内输入 @ 可链接) - -

-

- 文章 @ 存 token;小程序点 @ 用 token 换存客宝密钥后加好友/拉群。 -

-

- 超级个体:与「用户管理 → 超级个体列表」同口径的有效 VIP;每次打开/刷新本页时,后端会为尚未出现在本表的用户自动补一条可 @ 人物(昵称须合法、存客宝可用)。已配置统一计划时走「超级个体同步」获客计划。 -

-

- 飞书群 Webhook(按人推线索到群):在下方表格「飞书群」列查看状态,操作栏点 Webhook 图标配置;与绑定会员用户维度一致。 -

-

点「添加」新建的人物:每人单独一条存客宝计划(SOUL链接人与事-名称)。

-
- - -
-

本页只管理可 @ 的人物列表

-
- - -
-
-
- {persons.length > 0 ? ( - - - - - - - - - - - - - - - - - {persons.map(p => ( - - - - - - {(() => { - const leadCount = ckbLeadCounts[p.id] || 0 - return ( - - ) - })()} - - - - - - - ))} - -
token@的人来源会员获客数获客计划飞书群置顶状态操作
{p.id} - - - {p.personSource === 'vip_sync' ? ( - 超级个体 - ) : ( - 手工 - )} - - {p.userId ? ( -
- {p.personSource === 'vip_sync' && ( - 已绑定超级个体 - )} - -
- ) : ( - - )} -
0 ? 'text-green-400 hover:text-green-300 hover:underline' : 'text-gray-600'}`} - title={leadCount > 0 ? '点击查看获客详情' : '暂无获客'} - onClick={() => { if (leadCount > 0) openCkbLeadDetail(p.id, p.name) }} - > - {leadCount} - -
- - {p.ckbPlanId - ? (p.personSource === 'vip_sync' - ? '超级个体拉群(统一计划)' - : `SOUL链接人与事-${p.name}`) - : '—'} - -
-
- {p.userId ? ( - vipWebhookByUserId[(p.userId || '').trim()] ? ( - 已配置 - ) : ( - 未配置 - ) - ) : ( - - )} - - - - {p.ckbPlanId ? ( - 启用 - ) : ( - 未配置 - )} - -
- - - - -
-
- ) : ( -
暂无AI人物,添加后可在编辑器中 @链接
- )} -
-
- + {/* @列表已整合到用户管理→超级个体,保留 loadPersons/PersonAddEditModal 供编辑器用 */} - {/* 存客宝绑定配置(与 @列表 同一 tab) */} - - - - - 存客宝绑定 - -

- 配置存客宝 API 后,文章中 @人物 或 #标签 点击可自动进入存客宝流量池 -

-
- -
-
- - -
-
- - -
-
-

- 具体存客宝场景配置与接口测试请前往{' '} - -

-
-
- - - - - - 获客 Webhook 通知 - -

配置后新获客线索将自动推送到群聊(支持企业微信/飞书 Webhook)

-
- -
-
- - setCkbWebhookUrl(e.target.value)} - /> -
- -
-

配置企业微信或飞书群机器人 Webhook URL,获客成功后自动推送通知

-
-
- + {/* @列表已整合到用户管理→超级个体;PersonAddEditModal 仍保留供编辑器 @ 使用 */} {/* #链接标签管理 */} diff --git a/soul-admin/src/pages/dashboard/DashboardPage.tsx b/soul-admin/src/pages/dashboard/DashboardPage.tsx index 9c895267..ad3b56fc 100644 --- a/soul-admin/src/pages/dashboard/DashboardPage.tsx +++ b/soul-admin/src/pages/dashboard/DashboardPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3, UserPlus } from 'lucide-react' +import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react' import { get } from '@/api/client' import { UserDetailModal } from '@/components/modules/user/UserDetailModal' @@ -111,8 +111,7 @@ export function DashboardPage() { const [showDetailModal, setShowDetailModal] = useState(false) const [giftedTotal, setGiftedTotal] = useState(0) const [ordersExpanded, setOrdersExpanded] = useState(false) - const [ckbStats, setCkbStats] = useState<{ ckbTotal?: number; withContact?: number } | null>(null) - const [bottomTab, setBottomTab] = useState<'overview' | 'tags' | 'super'>('overview') + const [bottomTab, setBottomTab] = useState<'overview' | 'tags' | 'super' | 'journey'>('overview') const [superStats, setSuperStats] = useState< Array<{ userId: string; nickname?: string; avatar?: string; phone?: string; clicks: number; uniqueClicks: number; leadCount?: number }> >([]) @@ -144,6 +143,28 @@ export function DashboardPage() { } | null>(null) const [vipMembers, setVipMembers] = useState([]) + const [journeyStats, setJourneyStats] = useState>({}) + const [journeyLoading, setJourneyLoading] = useState(false) + + const JOURNEY_STAGES = [ + { id: 'register', label: '注册/登录', icon: '👤', color: 'bg-blue-500/20 border-blue-500/40 text-blue-400' }, + { id: 'browse', label: '浏览章节', icon: '📖', color: 'bg-purple-500/20 border-purple-500/40 text-purple-400' }, + { id: 'bind_phone', label: '绑定手机', icon: '📱', color: 'bg-cyan-500/20 border-cyan-500/40 text-cyan-400' }, + { id: 'first_pay', label: '首次付款', icon: '💳', color: 'bg-green-500/20 border-green-500/40 text-green-400' }, + { id: 'fill_profile', label: '完善资料', icon: '✍️', color: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400' }, + { id: 'match', label: '派对房匹配', icon: '🤝', color: 'bg-orange-500/20 border-orange-500/40 text-orange-400' }, + { id: 'vip', label: '升级 VIP', icon: '👑', color: 'bg-amber-500/20 border-amber-500/40 text-amber-400' }, + { id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]' }, + ] + + async function loadJourneyStats() { + setJourneyLoading(true) + try { + const data = await get<{ success?: boolean; stats?: Record }>('/api/db/users/journey-stats') + if (data?.success && data.stats) setJourneyStats(data.stats) + } catch { /* ignore */ } finally { setJourneyLoading(false) } + } + const showError = (err: unknown) => { const e = err as Error & { status?: number; name?: string } if (e?.status === 401) setLoadError('登录已过期,请重新登录') @@ -194,18 +215,6 @@ export function DashboardPage() { // 不影响主面板 } - // 加载获客信息(存客宝计划统计) - try { - const ckbRes = await get<{ success?: boolean; data?: { ckbTotal?: number; withContact?: number } }>('/api/db/ckb-plan-stats', init) - if (ckbRes?.success && ckbRes.data) { - setCkbStats({ ckbTotal: ckbRes.data.ckbTotal ?? 0, withContact: ckbRes.data.withContact ?? 0 }) - } else { - setCkbStats(null) - } - } catch { - setCkbStats(null) - } - // 加载「找伙伴 × 推广中心」共统计 setPartnerPromoLoading(true) try { @@ -575,28 +584,6 @@ export function DashboardPage() { bg: 'bg-orange-500/20', link: '/distribution', }, - { - title: '存客宝获客', - value: ckbStats ? ckbStats.ckbTotal ?? 0 : null, - sub: ckbStats?.withContact != null ? `含联系方式 ${ckbStats.withContact} 人` : null, - icon: UserPlus, - color: 'text-cyan-400', - bg: 'bg-cyan-500/20', - link: '/users?tab=leads', - }, - { - title: '伙伴&推广协同', - value: partnerPromoLoading - ? null - : (matchStats?.totalMatches ?? 0) + (distributionOverview?.totalClicks ?? 0), - sub: partnerPromoLoading - ? null - : `找伙伴 ${(matchStats?.totalMatches ?? 0)} / 推广 ${(distributionOverview?.totalClicks ?? 0)}`, - icon: BarChart3, - color: 'text-emerald-400', - bg: 'bg-emerald-500/20', - link: '/find-partner', - }, ] return ( @@ -614,7 +601,7 @@ export function DashboardPage() {
)} -
+
{stats.map((stat, index) => ( 超级个体统计 +
{bottomTab === 'overview' && ( @@ -1109,6 +1107,80 @@ export function DashboardPage() { )} + {bottomTab === 'journey' && ( + + + 用户旅程总览 + + + +

用户从注册到 VIP 的完整行动路径

+ {journeyLoading ? ( +
+ + 加载中... +
+ ) : ( + <> +
+
+
+ {JOURNEY_STAGES.map((stage, idx) => { + const count = journeyStats[stage.id] || 0 + return ( +
+
+
{stage.icon}
+
c.startsWith('text-'))}`}>{stage.label}
+
+ {count} 人 +
+
{idx + 1}
+
+
+ ) + })} +
+
+ {Object.keys(journeyStats).length > 0 && ( +
+

各阶段占比

+
+ {(() => { + const totalAll = JOURNEY_STAGES.reduce((s, st) => s + (journeyStats[st.id] || 0), 0) + return JOURNEY_STAGES.map((stage) => { + const count = journeyStats[stage.id] || 0 + const pct = totalAll > 0 ? Math.round((count / totalAll) * 100) : 0 + const barW = count > 0 ? Math.max(pct, 6) : 0 + return ( +
+ {stage.icon} {stage.label} +
+
+
+ {count} + {totalAll > 0 ? `${pct}%` : '—'} +
+ ) + }) + })()} +
+
+ )} + + )} + + + )} + { setShowDetailModal(false); setDetailUserId(null) }} diff --git a/soul-admin/src/pages/distribution/DistributionPage.tsx b/soul-admin/src/pages/distribution/DistributionPage.tsx index 40701dc7..6dce91bc 100644 --- a/soul-admin/src/pages/distribution/DistributionPage.tsx +++ b/soul-admin/src/pages/distribution/DistributionPage.tsx @@ -1,10 +1,12 @@ import toast from '@/utils/toast' -import { useState, useEffect } from 'react' -import { useSearchParams, useNavigate, Link } from 'react-router-dom' +import { useState, useEffect, useRef } from 'react' +import { useSearchParams } from 'react-router-dom' +import { + CkbLeadsPanel, + type CkbLeadsPanelHandle, +} from '@/components/modules/leads/CkbLeadsPanel' import { Users, - TrendingUp, - Clock, Wallet, Gift, Search, @@ -13,12 +15,12 @@ import { XCircle, DollarSign, Link2, - Eye, Undo2, Settings, Zap, } from 'lucide-react' import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage' +import { CKBConfigPanel } from '@/pages/find-partner/tabs/CKBConfigPanel' import { Pagination } from '@/components/ui/Pagination' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -34,35 +36,6 @@ import { } from '@/components/ui/dialog' import { get, put } from '@/api/client' -interface TodayClicksByPageItem { - page: string - clicks: number -} - -interface DistributionOverview { - todayClicks: number - todayBindings: number - todayConversions: number - todayEarnings: number - monthClicks: number - monthBindings: number - monthConversions: number - monthEarnings: number - totalClicks: number - totalBindings: number - totalConversions: number - totalEarnings: number - expiringBindings: number - pendingWithdrawals: number - pendingWithdrawAmount: number - conversionRate: string - totalDistributors: number - activeDistributors: number - todayUniqueVisitors?: number - todayClickRate?: number - todayClicksByPage?: TodayClicksByPageItem[] -} - interface Binding { id: string referrerId: string @@ -127,15 +100,14 @@ interface Order { } export function DistributionPage() { - const [searchParams] = useSearchParams() - const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const leadsPanelRef = useRef(null) const [activeTab, setActiveTab] = useState< - 'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings' - >('overview') + 'leads' | 'orders' | 'bindings' | 'withdrawals' | 'settings' | 'ckb' + >('leads') /** 订单 Tab 内:普通订单 / 代付请求 */ const [orderSubView, setOrderSubView] = useState<'orders' | 'giftpay'>('orders') const [orders, setOrders] = useState([]) - const [overview, setOverview] = useState(null) const [bindings, setBindings] = useState([]) const [withdrawals, setWithdrawals] = useState([]) const [users, setUsers] = useState([]) @@ -177,29 +149,31 @@ export function DistributionPage() { const [giftPayTotal, setGiftPayTotal] = useState(0) const [giftPayStatusFilter, setGiftPayStatusFilter] = useState('') - /** 旧链接「推广中心 → 获客情况」已合并至用户管理 → 获客列表 */ - useEffect(() => { - if (searchParams.get('tab') === 'leads') { - navigate('/users?tab=leads', { replace: true }) - } - }, [searchParams, navigate]) - useEffect(() => { loadInitialData() }, []) useEffect(() => { const t = searchParams.get('tab') - if ( - t === 'overview' || - t === 'orders' || - t === 'bindings' || - t === 'withdrawals' || - t === 'settings' - ) { - setActiveTab(t) + const valid = ['leads', 'orders', 'bindings', 'withdrawals', 'settings'] as const + const isValid = t && (valid as readonly string[]).includes(t) + const needLeads = + !t || t === 'overview' || !isValid + + if (needLeads) { + setSearchParams( + (prev) => { + const p = new URLSearchParams(prev) + p.set('tab', 'leads') + return p + }, + { replace: true }, + ) + setActiveTab('leads') + return } - }, [searchParams]) + setActiveTab(t as (typeof valid)[number]) + }, [searchParams, setSearchParams]) useEffect(() => { setPage(1) @@ -234,15 +208,6 @@ export function DistributionPage() { async function loadInitialData() { setError(null) - try { - const overviewData = await get<{ success?: boolean; overview?: DistributionOverview }>( - '/api/admin/distribution/overview', - ) - if (overviewData?.success && overviewData.overview) setOverview(overviewData.overview) - } catch (e) { - console.error('[Admin] 概览接口异常:', e) - setError('加载概览失败') - } try { const usersData = await get<{ success?: boolean; users?: User[] }>('/api/db/users') setUsers(usersData?.users || []) @@ -252,13 +217,17 @@ export function DistributionPage() { } async function loadTabData(tab: string, force = false) { + if (tab === 'leads') { + if (!force && loadedTabs.has(tab)) return + setLoadedTabs((prev) => new Set(prev).add('leads')) + setLoading(false) + return + } if (!force && loadedTabs.has(tab)) return setLoading(true) try { const usersArr = users switch (tab) { - case 'overview': - break case 'orders': { try { const params = new URLSearchParams({ @@ -388,13 +357,16 @@ export function DistributionPage() { async function refreshCurrentTab() { setError(null) + if (activeTab === 'leads') { + leadsPanelRef.current?.reload() + return + } setLoadedTabs((prev) => { const next = new Set(prev) next.delete(activeTab) if (activeTab === 'orders' && orderSubView === 'giftpay') next.delete('giftPay') return next }) - if (activeTab === 'overview') loadInitialData() if (activeTab === 'orders' && orderSubView === 'giftpay') { await loadTabData('giftPay', true) } else { @@ -582,7 +554,9 @@ export function DistributionPage() {

推广中心

-

分销绑定、提现审核、推广设置

+

+ 获客、绑定、收益、提现 · 存客宝配置 · 推广漏斗见「数据概览」 +

-
+
{[ - { key: 'overview', label: '数据概览', icon: TrendingUp }, - { key: 'orders', label: '订单与代付', icon: DollarSign }, - { key: 'bindings', label: '绑定管理', icon: Link2 }, - { key: 'withdrawals', label: '提现审核', icon: Wallet }, - { key: 'settings', label: '推广设置', icon: Settings }, + { key: 'leads' as const, label: '获客列表', icon: Users }, + { key: 'orders' as const, label: '订单与代付', icon: DollarSign }, + { key: 'bindings' as const, label: '绑定管理', icon: Link2 }, + { key: 'withdrawals' as const, label: '提现审核', icon: Wallet }, + { key: 'ckb' as const, label: '存客宝', icon: Zap }, + { key: 'settings' as const, label: '推广设置', icon: Settings }, ].map((tab) => ( ))} @@ -632,138 +613,8 @@ export function DistributionPage() {
) : ( <> - {activeTab === 'overview' && overview && ( -
- - -
- - - 推广转化漏斗 - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
指标今日本月累计
点击数{overview.todayClicks}{overview.monthClicks}{overview.totalClicks}
绑定关系{overview.todayBindings}{overview.monthBindings}{overview.totalBindings}
付款转化{overview.todayConversions}{overview.monthConversions}{overview.totalConversions}
佣金收入¥{(overview.todayEarnings ?? 0).toFixed(0)}¥{(overview.monthEarnings ?? 0).toFixed(0)}¥{(overview.totalEarnings ?? 0).toFixed(0)}
-
- {overview.conversionRate && ( -

综合转化率 {overview.conversionRate}

- )} -
-
- -
- - -
- -
-

即将过期绑定

-

{overview.expiringBindings} 个 · 7天内

-
-
-
-
- - -
- -
-

待审核提现

-

{overview.pendingWithdrawals} 笔 · ¥{(overview.pendingWithdrawAmount ?? 0).toFixed(0)}

-
- -
-
-
-
- - - -
-

获客线索(存客宝)

-

- 留资列表、推送状态与重试已统一至「用户管理 → 获客列表」,避免与推广中心重复维护。 -

-
- - - -
-
- - - -
-
- -
-

{overview.totalDistributors}

-

推广用户

-
-
-
- -
-

{overview.activeDistributors}

-

有收益用户

-
-
-
-
-
-
+ {activeTab === 'leads' && ( + )} {activeTab === 'orders' && ( @@ -1455,6 +1306,9 @@ export function DistributionPage() { + {/* 存客宝配置 Tab */} + {activeTab === 'ckb' && } + {/* 推广设置 Tab */} {activeTab === 'settings' && (
diff --git a/soul-admin/src/pages/find-partner/FindPartnerPage.tsx b/soul-admin/src/pages/find-partner/FindPartnerPage.tsx index 9b74491a..14f53221 100644 --- a/soul-admin/src/pages/find-partner/FindPartnerPage.tsx +++ b/soul-admin/src/pages/find-partner/FindPartnerPage.tsx @@ -1,24 +1,89 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Button } from '@/components/ui/button' -import { Users, Handshake, GraduationCap, UserPlus, Link2, ChevronRight } from 'lucide-react' +import { Users, Handshake, GraduationCap, UserPlus, RefreshCw, Settings } from 'lucide-react' import { FindPartnerTab } from './tabs/FindPartnerTab' import { ResourceDockingTab } from './tabs/ResourceDockingTab' import { MentorTab } from './tabs/MentorTab' import { TeamRecruitTab } from './tabs/TeamRecruitTab' -import { CKBConfigPanel } from './tabs/CKBConfigPanel' +import { get, post } from '@/api/client' +import toast from '@/utils/toast' -const TABS = [ - { id: 'partner', label: '找伙伴', icon: Users, desc: '匹配池与记录' }, - { id: 'resource', label: '资源对接', icon: Handshake, desc: '人脉资源' }, - { id: 'mentor', label: '导师预约', icon: GraduationCap, desc: '预约与管理' }, - { id: 'team', label: '团队招募', icon: UserPlus, desc: '团队协作' }, +const DEFAULT_TABS = [ + { id: 'partner', label: '找伙伴', icon: Users, desc: '匹配池与记录', hidden: false }, + { id: 'resource', label: '资源对接', icon: Handshake, desc: '人脉资源', hidden: false }, + { id: 'mentor', label: '导师预约', icon: GraduationCap, desc: '预约与管理', hidden: false }, + { id: 'team', label: '团队招募', icon: UserPlus, desc: '团队协作', hidden: false }, ] as const -type TabId = (typeof TABS)[number]['id'] +type TabId = (typeof DEFAULT_TABS)[number]['id'] + +interface TabConfig { + id: string + label?: string + hidden?: boolean +} export function FindPartnerPage() { const [activeTab, setActiveTab] = useState('partner') - const [showCKBPanel, setShowCKBPanel] = useState(false) + const [tabConfigs, setTabConfigs] = useState([]) + const [showConfigModal, setShowConfigModal] = useState(false) + const [editConfigs, setEditConfigs] = useState([]) + const [saving, setSaving] = useState(false) + + const loadTabConfigs = useCallback(async () => { + try { + const res = await get<{ success?: boolean; data?: TabConfig[] }>('/api/db/config/full?key=find_partner_tabs') + if (res?.success && Array.isArray(res.data)) setTabConfigs(res.data) + } catch { /* use defaults */ } + }, []) + + useEffect(() => { loadTabConfigs() }, [loadTabConfigs]) + + const visibleTabs = DEFAULT_TABS.filter((dt) => { + const cfg = tabConfigs.find((c) => c.id === dt.id) + return !(cfg?.hidden) + }).map((dt) => { + const cfg = tabConfigs.find((c) => c.id === dt.id) + return { ...dt, label: cfg?.label || dt.label } + }) + + useEffect(() => { + if (visibleTabs.length > 0 && !visibleTabs.some((t) => t.id === activeTab)) { + setActiveTab(visibleTabs[0].id) + } + }, [visibleTabs, activeTab]) + + const openConfig = () => { + setEditConfigs( + DEFAULT_TABS.map((dt) => { + const cfg = tabConfigs.find((c) => c.id === dt.id) + return { id: dt.id, label: cfg?.label || dt.label, hidden: cfg?.hidden || false } + }), + ) + setShowConfigModal(true) + } + + const saveConfig = async () => { + setSaving(true) + try { + const res = await post<{ success?: boolean }>('/api/db/config', { + key: 'find_partner_tabs', + value: editConfigs, + description: '找伙伴 Tab 配置(label/hidden)', + }) + if (res?.success) { + toast.success('Tab 配置已保存') + setTabConfigs(editConfigs) + setShowConfigModal(false) + } else { + toast.error('保存失败') + } + } catch { + toast.error('保存失败') + } finally { + setSaving(false) + } + } return (
@@ -29,39 +94,37 @@ export function FindPartnerPage() { 找伙伴

- 匹配、获客、导师与团队管理 · 汇总数据见「仪表盘」与「推广中心」 + 匹配、导师与团队管理 · 存客宝配置已移至「推广中心」

- {showCKBPanel && } - -
- {TABS.map((tab) => { +
+ {visibleTabs.map((tab) => { const isActive = activeTab === tab.id return ( ) @@ -72,6 +135,53 @@ export function FindPartnerPage() { {activeTab === 'resource' && } {activeTab === 'mentor' && } {activeTab === 'team' && } + + {/* Tab 配置弹窗 */} + {showConfigModal && ( +
setShowConfigModal(false)}> +
e.stopPropagation()}> +

找伙伴 Tab 配置

+

修改后保存即刻生效(管理端 + 小程序均读此配置)

+
+ {editConfigs.map((cfg, idx) => { + const dt = DEFAULT_TABS.find((t) => t.id === cfg.id) + return ( +
+ { + const next = [...editConfigs] + next[idx] = { ...cfg, hidden: !e.target.checked } + setEditConfigs(next) + }} + className="accent-[#38bdac]" + /> + { + const next = [...editConfigs] + next[idx] = { ...cfg, label: e.target.value } + setEditConfigs(next) + }} + placeholder={dt?.label} + /> + {dt?.id} +
+ ) + })} +
+
+ + +
+
+
+ )}
) } diff --git a/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx b/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx index 98cb79d5..f6696899 100644 --- a/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx @@ -29,16 +29,32 @@ interface PoolSettings { requireAvatar: boolean requireBusiness: boolean } +interface AlgorithmLayer { + id: string + label: string + desc: string + enabled: boolean + weight: number +} interface MatchConfig { matchTypes: MatchType[]; freeMatchLimit: number; matchPrice: number settings: { enableFreeMatches: boolean; enablePaidMatches: boolean; maxMatchesPerDay: number } poolSettings?: PoolSettings + algorithmLayers?: AlgorithmLayer[] } const DEFAULT_POOL: PoolSettings = { poolSource: ['vip'], requirePhone: true, requireNickname: true, requireAvatar: false, requireBusiness: false, } +const DEFAULT_ALGORITHM_LAYERS: AlgorithmLayer[] = [ + { id: 'tag_match', label: '标签匹配', desc: '优先匹配标签相似的用户(行业、职位、兴趣)', enabled: true, weight: 30 }, + { id: 'journey_match', label: '旅程匹配', desc: '匹配处于相似用户旅程阶段的用户', enabled: true, weight: 20 }, + { id: 'behavior_match', label: '行为匹配', desc: '匹配行为轨迹类似的用户(浏览、付费、社交)', enabled: true, weight: 15 }, + { id: 'mbti_complement', label: 'MBTI 互补', desc: '匹配性格互补的用户(如 INTJ ↔ ENFP)', enabled: true, weight: 20 }, + { id: 'gender_match', label: '男女匹配', desc: '当以上条件无法匹配时,优先匹配异性', enabled: true, weight: 10 }, + { id: 'random', label: '随机匹配', desc: '最终兜底:完全随机匹配池内用户', enabled: true, weight: 5 }, +] const DEFAULT_CONFIG: MatchConfig = { matchTypes: [ { id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true }, @@ -49,6 +65,7 @@ const DEFAULT_CONFIG: MatchConfig = { freeMatchLimit: 3, matchPrice: 1, settings: { enableFreeMatches: true, enablePaidMatches: true, maxMatchesPerDay: 10 }, poolSettings: DEFAULT_POOL, + algorithmLayers: DEFAULT_ALGORITHM_LAYERS, } const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨'] @@ -225,6 +242,68 @@ export function MatchPoolTab() { + {/* 匹配算法配置 */} + + + + 匹配算法 + + + 按优先级从高到低执行:先尝试标签匹配,逐层降级,最终兜底随机匹配。权重影响综合评分。 + + + +
+ {(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS).map((layer, idx) => ( +
+ #{idx + 1} + { + const layers = [...(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS)] + layers[idx] = { ...layers[idx], enabled: v } + setConfig({ ...config, algorithmLayers: layers }) + }} + /> +
+
+ {layer.label} + + 权重 {layer.weight}% + +
+

{layer.desc}

+
+ { + const layers = [...(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS)] + layers[idx] = { ...layers[idx], weight: parseInt(e.target.value, 10) || 0 } + setConfig({ ...config, algorithmLayers: layers }) + }} + title="权重(0-100)" + /> +
+ ))} +
+

+ 总权重:{(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS).filter(l => l.enabled).reduce((s, l) => s + l.weight, 0)}% + {' · '}修改后点击上方「保存配置」生效 +

+
+
+ 基础设置 diff --git a/soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx b/soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx index e845808e..4ec4703c 100644 --- a/soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Button } from '@/components/ui/button' @@ -8,15 +8,30 @@ import { get } from '@/api/client' interface Consultation { id: number; userId: number; mentorId: number; consultationType: string amount: number; status: string; createdAt: string + mentorName?: string; userName?: string } +interface MentorLite { id: number; name?: string } + const statusMap: Record = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' } const typeMap: Record = { single: '单次', half_year: '半年', year: '年度' } -export function MentorBookingTab() { +export function MentorBookingTab({ onSwitchToManage }: { onSwitchToManage?: () => void }) { const [list, setList] = useState([]) const [loading, setLoading] = useState(true) const [statusFilter, setStatusFilter] = useState('') + const [mentorMap, setMentorMap] = useState>({}) + + const loadMentors = useCallback(async () => { + try { + const data = await get<{ success?: boolean; data?: MentorLite[] }>('/api/db/mentors') + if (data?.success && Array.isArray(data.data)) { + const m: Record = {} + for (const mt of data.data) if (mt.id && mt.name) m[mt.id] = mt.name + setMentorMap(m) + } + } catch { /* ignore */ } + }, []) async function load() { setLoading(true) @@ -28,6 +43,7 @@ export function MentorBookingTab() { finally { setLoading(false) } } + useEffect(() => { loadMentors() }, [loadMentors]) useEffect(() => { load() }, [statusFilter]) return ( @@ -55,8 +71,8 @@ export function MentorBookingTab() { ID - 用户ID - 导师ID + 用户 + 导师 类型 金额 状态 @@ -64,17 +80,29 @@ export function MentorBookingTab() { - {list.map(r => ( - - {r.id} - {r.userId} - {r.mentorId} - {typeMap[r.consultationType] || r.consultationType} - ¥{r.amount} - {statusMap[r.status] || r.status} - {r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} - - ))} + {list.map(r => { + const mName = r.mentorName || mentorMap[r.mentorId] || `#${r.mentorId}` + return ( + + {r.id} + {r.userName || r.userId} + + + + {typeMap[r.consultationType] || r.consultationType} + ¥{r.amount} + {statusMap[r.status] || r.status} + {r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'} + + ) + })} {list.length === 0 && 暂无预约记录} diff --git a/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx b/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx index 7150d949..ce73753e 100644 --- a/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/MentorTab.tsx @@ -17,7 +17,7 @@ export function MentorTab() { 导师管理
- {subTab === 'booking' && } + {subTab === 'booking' && setSubTab('manage')} />} {subTab === 'manage' && (
diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index db74ec65..0747e426 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -703,6 +703,13 @@ export function SettingsPage() {

弹窗类文案在「弹窗文案」子 Tab 按页面路径 + 英文键维护(pagePopupItems);目录、Tab、首页板块等仍由其它配置决定。

+

+ 首页右上角「打赏 / 上麦」配置已按超级个体独立管理,请到{' '} + + 用户管理 → 超级个体 + + ,展开对应成员行即可配置。 +

diff --git a/soul-admin/src/pages/users/HomeEntryConfigTab.tsx b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx new file mode 100644 index 00000000..46154ad4 --- /dev/null +++ b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx @@ -0,0 +1,576 @@ +import { useState, useCallback, useEffect } from 'react' +import toast from '@/utils/toast' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Gift, Mic, RefreshCw, Plus, Trash2 } from 'lucide-react' +import { get, post } from '@/api/client' + +type GiftRow = { + id: string + name: string + price: number + emoji: string + icon: string + /** 上麦横排从左到右,数字越小越靠左 */ + micOrder: number + /** 相对基础 emoji 尺寸倍数,默认 1 */ + iconScale: number + /** 默认支付档(上麦弹窗高亮 + 放大系数基准档) */ + micFeatured: boolean +} + +const DEFAULT_GIFTS: GiftRow[] = [ + { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '', micOrder: 0, iconScale: 1, micFeatured: false }, + { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '', micOrder: 1, iconScale: 1, micFeatured: true }, + { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '', micOrder: 2, iconScale: 1, micFeatured: false }, +] + +function asRecord(v: unknown): Record { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {} +} + +function normBool(v: unknown): boolean { + return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true' +} + +function parseGifts(raw: unknown): GiftRow[] { + if (!Array.isArray(raw)) return [...DEFAULT_GIFTS] + const out: GiftRow[] = [] + for (let i = 0; i < raw.length; i++) { + const g = asRecord(raw[i]) + const d = DEFAULT_GIFTS[Math.min(i, DEFAULT_GIFTS.length - 1)] + const id = String(g.id ?? d.id).trim() || d.id + const name = String(g.name ?? d.name).trim() || d.name + let price = parseFloat(String(g.price ?? d.price)) + if (Number.isNaN(price) || price <= 0) price = d.price + let micOrder = 999 + if (g.micOrder !== undefined && g.micOrder !== null && String(g.micOrder) !== '') { + const p = parseInt(String(g.micOrder), 10) + if (!Number.isNaN(p)) micOrder = p + } + let iconScale = parseFloat(String(g.iconScale ?? '')) + if (Number.isNaN(iconScale) || iconScale <= 0) iconScale = 1 + const micFeatured = 'micFeatured' in g ? normBool(g.micFeatured) : false + out.push({ + id, + name, + price, + emoji: String(g.emoji ?? d.emoji).trim() || d.emoji, + icon: String(g.icon ?? '').trim(), + micOrder, + iconScale, + micFeatured, + }) + } + return out.length ? out : [...DEFAULT_GIFTS] +} + +export function HomeEntryConfigTab() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [baselineMpConfig, setBaselineMpConfig] = useState | null>(null) + + const [rewardEnabled, setRewardEnabled] = useState(false) + const [entryButtonText, setEntryButtonText] = useState('支持一下') + const [modalTitle, setModalTitle] = useState('打赏支持') + const [modalSubtitle, setModalSubtitle] = useState('感谢支持,任选礼物与数量') + const [payButtonText, setPayButtonText] = useState('立即支付') + const [leadLinkText, setLeadLinkText] = useState('留下联系方式') + const [afterPayToast, setAfterPayToast] = useState('支付成功,感谢支持!') + const [gifts, setGifts] = useState([...DEFAULT_GIFTS]) + + const [micEnabled, setMicEnabled] = useState(false) + const [micStart, setMicStart] = useState('18:00') + const [micEnd, setMicEnd] = useState('21:00') + const [micEmoji, setMicEmoji] = useState('🎤') + const [micButtonText, setMicButtonText] = useState('申请上麦') + const [micModalTitle, setMicModalTitle] = useState('申请上麦') + const [micModalContent, setMicModalContent] = useState( + '当前为直播时段,提交后工作人员将按顺序处理连麦申请。', + ) + const [micAfterSubmitToast, setMicAfterSubmitToast] = useState('支付成功,感谢支持!') + const [paidWebhookUrl, setPaidWebhookUrl] = useState('') + /** 默认档 emoji / 图标再放大倍数(相对两侧),建议 1.45–1.8 */ + const [micCenterEmojiScale, setMicCenterEmojiScale] = useState(1.55) + + const applyFromMpConfig = useCallback((mp: Record) => { + const mpUi = asRecord(mp.mpUi) + const home = asRecord(mpUi.homePage) + const rw = asRecord(home.linkKaruoReward) + const sch = asRecord(home.liveMicSchedule) + + setRewardEnabled(normBool(rw.enabled)) + setEntryButtonText(String(rw.entryButtonText || '支持一下')) + setModalTitle(String(rw.modalTitle || '打赏支持')) + setModalSubtitle(String(rw.modalSubtitle || '感谢支持,任选礼物与数量')) + setPayButtonText(String(rw.payButtonText || '立即支付')) + setLeadLinkText(String(rw.leadLinkText || '留下联系方式')) + setAfterPayToast(String(rw.afterPayToast || '支付成功,感谢支持!')) + setGifts(parseGifts(rw.gifts)) + + setMicEnabled(normBool(sch.enabled)) + setMicStart(String(sch.micStart || '18:00').trim() || '18:00') + setMicEnd(String(sch.micEnd || '21:00').trim() || '21:00') + setMicEmoji(String(sch.micEmoji || '🎤').trim() || '🎤') + setMicButtonText(String(sch.micButtonText || '申请上麦')) + setMicModalTitle(String(sch.micModalTitle || '申请上麦')) + setMicModalContent( + String(sch.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。'), + ) + setMicAfterSubmitToast(String(sch.micAfterSubmitToast || '支付成功,感谢支持!')) + setPaidWebhookUrl(String(sch.paidWebhookUrl || '').trim()) + { + const sc = parseFloat(String(sch.micCenterEmojiScale ?? '1.55')) + setMicCenterEmojiScale(!Number.isNaN(sc) && sc >= 1 ? Math.min(sc, 2.4) : 1.55) + } + }, []) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await get<{ + success?: boolean + mpConfig?: Record + }>('/api/admin/settings') + if (!res || res.success === false) { + toast.error('加载设置失败') + return + } + const mp = res.mpConfig && typeof res.mpConfig === 'object' ? { ...res.mpConfig } : {} + setBaselineMpConfig(mp) + applyFromMpConfig(mp) + } catch { + toast.error('加载设置失败') + } finally { + setLoading(false) + } + }, [applyFromMpConfig]) + + useEffect(() => { + void load() + }, [load]) + + const handleSave = async () => { + if (!baselineMpConfig) { + toast.error('请先等待配置加载完成') + return + } + setSaving(true) + try { + const mpUi = { ...asRecord(baselineMpConfig.mpUi) } + const prevHome = asRecord(mpUi.homePage) + const linkKaruoReward = { + ...asRecord(prevHome.linkKaruoReward), + enabled: rewardEnabled, + entryButtonText, + modalTitle, + modalSubtitle, + payButtonText, + leadLinkText, + afterPayToast, + gifts: gifts.map((g) => ({ + id: g.id, + name: g.name, + price: g.price, + emoji: g.emoji, + icon: g.icon || '', + micOrder: g.micOrder, + iconScale: g.iconScale, + micFeatured: g.micFeatured, + })), + } + const liveMicSchedule = { + ...asRecord(prevHome.liveMicSchedule), + enabled: micEnabled, + micStart, + micEnd, + micEmoji, + micButtonText, + micModalTitle, + micModalContent, + micAfterSubmitToast, + paidWebhookUrl: paidWebhookUrl.trim(), + micCenterEmojiScale, + } + mpUi.homePage = { + ...prevHome, + linkKaruoReward, + liveMicSchedule, + } + const mpConfig = { ...baselineMpConfig, mpUi } + const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', { + mpConfig, + }) + if (!res || res.success === false) { + toast.error(res?.error || '保存失败') + return + } + toast.success('已保存,小程序拉取配置后生效') + setBaselineMpConfig(mpConfig) + } catch (e) { + toast.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSaving(false) + } + } + + const updateGift = (index: number, patch: Partial) => { + setGifts((rows) => rows.map((r, i) => (i === index ? { ...r, ...patch } : r))) + } + + const addGift = () => { + setGifts((rows) => [ + ...rows, + { + id: `gift_${Date.now()}`, + name: '新礼物', + price: 1, + emoji: '🎁', + icon: '', + micOrder: rows.length, + iconScale: 1, + micFeatured: false, + }, + ]) + } + + const removeGift = (index: number) => { + setGifts((rows) => rows.filter((_, i) => i !== index)) + } + + if (loading) { + return ( +
+ + 加载首页入口配置… +
+ ) + } + + return ( +
+

+ 配置小程序首页右上角 + 入口:后台保存后,用户每次进入首页会拉最新配置 + (无需发版)。非直播时段走「链接 / 打赏」或置顶;直播时段入口可切为「申请上麦」,点击后打开 + 与上方相同的礼物支付弹窗,支付成功推送见下方 Webhook。 +

+ + + + + + 打赏弹窗(链接 / 支持) + + + 非直播时段或未开启「定时上麦」时,与置顶 @ 人物逻辑并存:有置顶时优先展示置顶链接文案;仅开启打赏且无置顶时展示入口按钮文案。 + + + +
+
+ +

关闭后仅保留置顶人物「点击链接」行为(若有置顶)

+
+ +
+
+
+ + setEntryButtonText(e.target.value)} + /> +
+
+ + setModalTitle(e.target.value)} + /> +
+
+ + setModalSubtitle(e.target.value)} + /> +
+
+ + setPayButtonText(e.target.value)} + /> +
+
+ + setLeadLinkText(e.target.value)} + /> +
+
+ + setAfterPayToast(e.target.value)} + /> +
+
+ +
+
+ + +
+

+ 上麦横排:排序数字越小越靠左;图标缩放×相对基础大小;仅一条可设为「默认支付档」(高亮 + 与下方「默认档放大倍数」相乘)。 +

+
+ {gifts.map((g, i) => ( +
+
+ id + updateGift(i, { id: e.target.value })} + /> +
+
+ 名称 + updateGift(i, { name: e.target.value })} + /> +
+
+ 价格 + updateGift(i, { price: parseFloat(e.target.value) || 0 })} + /> +
+
+ emoji + updateGift(i, { emoji: e.target.value })} + /> +
+
+ +
+
+ 图标 URL(可选) + updateGift(i, { icon: e.target.value })} + placeholder="https://..." + /> +
+
+ 上麦排序 + updateGift(i, { micOrder: parseInt(e.target.value, 10) || 0 })} + /> +
+
+ 图标缩放× + + updateGift(i, { iconScale: parseFloat(e.target.value) || 1 }) + } + /> +
+
+ { + setGifts((rows) => + rows.map((r, j) => + j === i ? { ...r, micFeatured: v } : v ? { ...r, micFeatured: false } : r, + ), + ) + }} + /> + 默认支付档 +
+
+ ))} +
+
+
+
+ + + + + + 直播时段 · 申请上麦 + + + 在时段内右上角显示配置的入口文案与图标;用户点击后弹出礼物选择 + 微信支付(礼物列表与上图「打赏弹窗」共用)。支付成功后订单推送:若填写了「上麦支付 + Webhook」则推到该地址,否则与系统设置里订单支付默认 Webhook(order_paid_webhook_url / + 获客 ckb_lead_webhook_url)一致。 + + + +
+
+ +

例:18:00–21:00 显示上麦入口,点击走礼物支付;其余时间显示打赏或置顶

+
+ +
+
+
+ + setMicStart(e.target.value)} + /> +
+
+ + setMicEnd(e.target.value)} + /> +
+
+ + setMicEmoji(e.target.value)} + placeholder="🎤" + /> +
+
+ + setMicButtonText(e.target.value)} + /> +
+
+
+ + setMicModalTitle(e.target.value)} + /> +
+
+ +