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}}
-
+
+
+
+
+ {{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 中抓取远程图片( 与
),下载到本地 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()
+ }}
+ />
+
+
+
+ )
+ },
+)
+
+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 ? (
-

- ) : (
-
未配
- )}
-
-
- setAvatars((prev) => ({ ...prev, [t]: e.target.value }))}
- />
-
-
-
-
-
-
+
+
+ {url ?
})
{ (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 ? (
-
-
-
- | token |
- @的人 |
- 来源 |
- 会员 |
- 获客数 |
- 获客计划 |
- 飞书群 |
- 置顶 |
- 状态 |
- 操作 |
-
-
-
- {persons.map(p => (
-
- | {p.id} |
-
-
- |
-
- {p.personSource === 'vip_sync' ? (
- 超级个体
- ) : (
- 手工
- )}
- |
-
- {p.userId ? (
-
- {p.personSource === 'vip_sync' && (
- 已绑定超级个体
- )}
-
-
- ) : (
- —
- )}
- |
- {(() => {
- const leadCount = ckbLeadCounts[p.id] || 0
- return (
- 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 配置
+
修改后保存即刻生效(管理端 + 小程序均读此配置)
+
+
+
+
+
+
+
+ )}
)
}
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。
+
+
+
+
+
+
+ 打赏弹窗(链接 / 支持)
+
+
+ 非直播时段或未开启「定时上麦」时,与置顶 @ 人物逻辑并存:有置顶时优先展示置顶链接文案;仅开启打赏且无置顶时展示入口按钮文案。
+
+
+
+
+
+
+
关闭后仅保留置顶人物「点击链接」行为(若有置顶)
+
+
+
+
+
+
+
+
+ 上麦横排:排序数字越小越靠左;图标缩放×相对基础大小;仅一条可设为「默认支付档」(高亮 + 与下方「默认档放大倍数」相乘)。
+
+
+ {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 显示上麦入口,点击走礼物支付;其余时间显示打赏或置顶
+
+
+
+
+
+
+ setMicModalTitle(e.target.value)}
+ />
+
+
+
+
+
+
+
setMicCenterEmojiScale(parseFloat(e.target.value) || 1.55)}
+ />
+
仅作用于「默认支付档」相对两侧的 emoji/图标大小,与礼物行的「图标缩放」相乘。
+
+
+
+ setMicAfterSubmitToast(e.target.value)}
+ />
+
+
+
+
setPaidWebhookUrl(e.target.value)}
+ />
+
+ 仅当用户从直播时段上麦入口完成礼物支付时推送到此地址;留空则走订单默认群 Webhook(与「打赏」同一套兜底)。
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/soul-admin/src/pages/users/SuperIndividualTab.tsx b/soul-admin/src/pages/users/SuperIndividualTab.tsx
new file mode 100644
index 00000000..4c305b09
--- /dev/null
+++ b/soul-admin/src/pages/users/SuperIndividualTab.tsx
@@ -0,0 +1,727 @@
+import React, { useState, useCallback, useEffect } from 'react'
+import toast from '@/utils/toast'
+import { normalizeImageUrl } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Switch } from '@/components/ui/switch'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from '@/components/ui/table'
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from '@/components/ui/dialog'
+import {
+ Crown, Tag, ArrowUpDown, RefreshCw, Save, X, Gift, Trash2, Plus,
+ Pin, ChevronDown, ChevronRight, Settings2, Webhook, Copy, Pencil, Stethoscope,
+} from 'lucide-react'
+import { get, put, post, del } from '@/api/client'
+import { PersonAddEditModal, type PersonFormData } from '@/pages/content/PersonAddEditModal'
+import { getPersonDetail } from '@/api/ckb'
+import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
+
+interface VipMember {
+ id: string
+ name: string
+ avatar?: string | null
+ mbti?: string | null
+ vipRole?: string | null
+ vipSort?: number | null
+ clickCount?: number | null
+ leadCount?: number | null
+ personId?: string | null
+ personToken?: string | null
+ isPinned?: boolean
+ personName?: string | null
+ personSource?: string | null
+ ckbPlanId?: number | null
+ ckbApiKey?: string | null
+ homeEntryConfig?: HomeEntryConfig | null
+ webhookUrl?: string | null
+}
+
+interface GiftRow { id: string; name: string; price: number; emoji: string; icon: string }
+interface HomeEntryConfig {
+ linkKaruoReward?: {
+ enabled?: boolean; entryButtonText?: string; modalTitle?: string
+ modalSubtitle?: string; payButtonText?: string; afterPayToast?: string
+ gifts?: GiftRow[]
+ }
+ liveMicSchedule?: {
+ enabled?: boolean; micStart?: string; micEnd?: string; micEmoji?: string
+ micButtonText?: string; paidWebhookUrl?: string
+ }
+}
+
+const DEFAULT_GIFTS: GiftRow[] = [
+ { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '' },
+ { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '' },
+ { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '' },
+]
+const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
+
+type CkbPlanHealth = { status: string; error?: string }
+
+function normBool(v: unknown): boolean {
+ return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
+}
+function avatarSrc(a?: string | null): string { return a ? normalizeImageUrl(a) : '' }
+
+export function SuperIndividualTab() {
+ const [members, setMembers] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [expandedId, setExpandedId] = useState(null)
+ const [draggingId, setDraggingId] = useState(null)
+ const [dragOverId, setDragOverId] = useState(null)
+
+ const [showRoleModal, setShowRoleModal] = useState(false)
+ const [roleTarget, setRoleTarget] = useState(null)
+ const [roleInput, setRoleInput] = useState('')
+ const [roleSaving, setRoleSaving] = useState(false)
+
+ const [showSortModal, setShowSortModal] = useState(false)
+ const [sortTarget, setSortTarget] = useState(null)
+ const [sortInput, setSortInput] = useState('')
+ const [sortSaving, setSortSaving] = useState(false)
+
+ const [showWebhookModal, setShowWebhookModal] = useState(false)
+ const [webhookTarget, setWebhookTarget] = useState(null)
+ const [webhookInput, setWebhookInput] = useState('')
+ const [webhookSaving, setWebhookSaving] = useState(false)
+
+ const [personModalOpen, setPersonModalOpen] = useState(false)
+ const [editingPersonData, setEditingPersonData] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+
+ const [userDetailId, setUserDetailId] = useState(null)
+
+ const [leadDetailOpen, setLeadDetailOpen] = useState(false)
+ const [leadDetailName, setLeadDetailName] = useState('')
+ const [leadDetailRecords, setLeadDetailRecords] = useState([])
+ const [leadDetailLoading, setLeadDetailLoading] = useState(false)
+
+ /** personId -> 最近一次存客宝计划探测结果(与 /api/admin/ckb/plan-check 对齐) */
+ const [planHealthByPerson, setPlanHealthByPerson] = useState>({})
+ const [planCheckLoading, setPlanCheckLoading] = useState(false)
+ const [planCheckRowPid, setPlanCheckRowPid] = useState(null)
+
+ const mergePlanCheckResults = useCallback((plans: { personId?: string; status?: string; error?: string }[]) => {
+ setPlanHealthByPerson((prev) => {
+ const next = { ...prev }
+ for (const p of plans) {
+ const pid = (p.personId || '').trim()
+ if (!pid) continue
+ next[pid] = { status: p.status || 'unknown', error: typeof p.error === 'string' ? p.error : undefined }
+ }
+ return next
+ })
+ }, [])
+
+ const checkAllCkbPlans = async () => {
+ setPlanCheckLoading(true)
+ try {
+ const d = await get<{
+ success?: boolean
+ error?: string
+ plans?: { personId?: string; status?: string; error?: string }[]
+ message?: string
+ }>('/api/admin/ckb/plan-check')
+ if (!d?.success) {
+ toast.error(d?.error || '检测失败')
+ return
+ }
+ mergePlanCheckResults(d.plans || [])
+ const n = (d.plans || []).length
+ toast.success(n > 0 ? `已检测 ${n} 个存客宝计划` : (d.message || '无待检测计划'))
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '检测请求失败')
+ } finally {
+ setPlanCheckLoading(false)
+ }
+ }
+
+ const checkOneCkbPlan = async (personId: string) => {
+ const pid = personId.trim()
+ if (!pid) return
+ setPlanCheckRowPid(pid)
+ try {
+ const d = await get<{
+ success?: boolean
+ error?: string
+ plans?: { personId?: string; status?: string; error?: string }[]
+ message?: string
+ }>(`/api/admin/ckb/plan-check?personId=${encodeURIComponent(pid)}`)
+ if (!d?.success) {
+ toast.error(d?.error || '检测失败')
+ return
+ }
+ mergePlanCheckResults(d.plans || [])
+ const row = (d.plans || [])[0]
+ if (row?.status === 'online') toast.success('存客宝计划连通正常')
+ else if (row?.status === 'error') toast.error(row.error || '存客宝计划异常')
+ else toast.info(d.message || '无返回')
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '检测请求失败')
+ } finally {
+ setPlanCheckRowPid(null)
+ }
+ }
+
+ const openLeadDetail = async (m: VipMember) => {
+ if (!m.personToken) return
+ setLeadDetailName(m.name)
+ setLeadDetailOpen(true)
+ setLeadDetailLoading(true)
+ try {
+ const d = await get<{ success?: boolean; records?: any[] }>(`/api/db/ckb-leads?token=${encodeURIComponent(m.personToken)}&limit=50`)
+ setLeadDetailRecords(d?.records || [])
+ } catch { setLeadDetailRecords([]) } finally { setLeadDetailLoading(false) }
+ }
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const d = await get<{ success?: boolean; data?: VipMember[] }>('/api/db/vip-members?limit=500')
+ if (d?.success && d.data) {
+ setMembers(
+ [...d.data]
+ .map((m, i) => ({ ...m, vipSort: typeof m.vipSort === 'number' ? m.vipSort : i + 1 }))
+ .sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999)),
+ )
+ }
+ } catch { toast.error('加载失败') } finally { setLoading(false) }
+ }, [])
+
+ useEffect(() => { void load() }, [load])
+
+ const togglePin = async (m: VipMember) => {
+ if (!m.personId) { toast.error('未关联@人物'); return }
+ try {
+ const r = await put<{ success?: boolean; isPinned?: boolean }>('/api/db/persons/pin', { personId: m.personId, isPinned: !m.isPinned })
+ if (r?.success) { toast.success(r.isPinned ? '已置顶' : '已取消置顶'); await load() }
+ } catch { toast.error('操作失败') }
+ }
+
+ const saveRole = async () => {
+ const v = roleInput.trim()
+ if (!roleTarget || !v) return
+ setRoleSaving(true)
+ try {
+ const r = await put<{ success?: boolean }>('/api/db/users', { id: roleTarget.id, vipRole: v })
+ if (r?.success) { toast.success('已更新'); setShowRoleModal(false); await load() }
+ } catch { toast.error('失败') } finally { setRoleSaving(false) }
+ }
+
+ const saveSort = async () => {
+ if (!sortTarget) return
+ const n = Number(sortInput)
+ if (!Number.isFinite(n)) { toast.error('请输入数字'); return }
+ setSortSaving(true)
+ try {
+ const r = await put<{ success?: boolean }>('/api/db/users', { id: sortTarget.id, vipSort: n })
+ if (r?.success) { toast.success('已更新'); setShowSortModal(false); await load() }
+ } catch { toast.error('失败') } finally { setSortSaving(false) }
+ }
+
+ const saveWebhook = async () => {
+ if (!webhookTarget) return
+ setWebhookSaving(true)
+ try {
+ const r = await put<{ success?: boolean }>('/api/db/vip-members/webhook', { userId: webhookTarget.id, webhookUrl: webhookInput.trim() })
+ if (r?.success) { toast.success('Webhook 已保存'); setShowWebhookModal(false); await load() }
+ } catch { toast.error('失败') } finally { setWebhookSaving(false) }
+ }
+
+ const handleDragStart = (e: React.DragEvent, id: string) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); setDraggingId(id) }
+ const handleDragOver = (e: React.DragEvent, id: string) => { e.preventDefault(); if (dragOverId !== id) setDragOverId(id) }
+ const handleDragEnd = () => { setDraggingId(null); setDragOverId(null) }
+ const handleDrop = async (e: React.DragEvent, toId: string) => {
+ e.preventDefault()
+ const fromId = e.dataTransfer.getData('text/plain') || draggingId
+ setDraggingId(null); setDragOverId(null)
+ if (!fromId || fromId === toId) return
+ const f = members.find(m => m.id === fromId), t = members.find(m => m.id === toId)
+ if (!f || !t) return
+ const fs = f.vipSort ?? members.indexOf(f) + 1, ts = t.vipSort ?? members.indexOf(t) + 1
+ setMembers(prev => {
+ const n = [...prev]; const fi = n.findIndex(m => m.id === fromId); const ti = n.findIndex(m => m.id === toId)
+ if (fi === -1 || ti === -1) return prev
+ n[fi] = { ...prev[ti], vipSort: fs }; n[ti] = { ...prev[fi], vipSort: ts }; return n
+ })
+ try {
+ await Promise.all([put('/api/db/users', { id: fromId, vipSort: ts }), put('/api/db/users', { id: toId, vipSort: fs })])
+ toast.success('排序已更新'); await load()
+ } catch { toast.error('排序失败'); await load() }
+ }
+
+ const copyToken = (t: string) => { navigator.clipboard.writeText(t).then(() => toast.success('Token 已复制')).catch(() => toast.error('复制失败')) }
+
+ const openEditPerson = async (m: VipMember) => {
+ if (!m.personId) { toast.error('未关联@人物'); return }
+ try {
+ const res = await getPersonDetail(m.personId)
+ if (res?.success && res.person) {
+ const d = res.person
+ setEditingPersonData({
+ id: d.token ?? d.personId,
+ personId: d.personId,
+ name: d.name,
+ personSource: (d as any).personSource ?? '',
+ userId: d.userId ?? '',
+ label: d.label ?? '',
+ ckbApiKey: d.ckbApiKey ?? '',
+ remarkType: d.remarkType,
+ remarkFormat: d.remarkFormat,
+ addFriendInterval: d.addFriendInterval,
+ startTime: d.startTime,
+ endTime: d.endTime,
+ deviceGroups: d.deviceGroups,
+ })
+ } else {
+ setEditingPersonData({ id: m.personToken, personId: m.personId, name: m.name, userId: m.id })
+ }
+ } catch {
+ setEditingPersonData({ id: m.personToken, personId: m.personId, name: m.name, userId: m.id })
+ }
+ setPersonModalOpen(true)
+ }
+
+ const handlePersonSubmit = async (data: PersonFormData) => {
+ const dgStr = String(data.deviceGroups || '').trim()
+ const deviceGroups = dgStr
+ ? dgStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n))
+ : []
+ const payload = {
+ personId: data.personId || (data.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)),
+ name: data.name,
+ userId: data.boundUserId,
+ aliases: data.aliases || undefined,
+ label: data.label,
+ ckbApiKey: data.ckbApiKey || undefined,
+ greeting: data.greeting || undefined,
+ tips: data.tips || undefined,
+ remarkType: data.remarkType || undefined,
+ remarkFormat: data.remarkFormat || undefined,
+ addFriendInterval: data.addFriendInterval || 1,
+ startTime: data.startTime || '09:00',
+ endTime: data.endTime || '18:00',
+ deviceGroups,
+ }
+ const res = await post<{ success?: boolean; error?: string }>('/api/db/persons', payload)
+ if (!res?.success) throw new Error(res?.error || '操作失败')
+ await load()
+ toast.success(editingPersonData ? '已保存' : '已添加')
+ }
+
+ const confirmDeletePerson = async () => {
+ if (!deleteTarget?.personId) return
+ try {
+ await del(`/api/db/persons?personId=${deleteTarget.personId}`)
+ toast.success('已删除')
+ setDeleteTarget(null)
+ await load()
+ } catch { toast.error('删除失败') }
+ }
+
+ return (
+
+
+
以超级个体为核心:排序、标签、置顶、打赏/上麦、Webhook、@人物管理。拖拽排序。
+
+
+
+
+
+
+
+ {loading ? (
+
加载中...
+ ) : members.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+ #
+ 成员
+ 📌
+ 获客
+ 点击
+ 计划
+ 操作
+
+
+
+ {members.map((m, idx) => {
+ const ex = expandedId === m.id
+ const hasPlan = !!m.ckbPlanId
+ return (
+
+ handleDragStart(e, m.id)} onDragOver={e => handleDragOver(e, m.id)}
+ onDrop={e => handleDrop(e, m.id)} onDragEnd={handleDragEnd}
+ className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${draggingId === m.id ? 'opacity-60' : ''} ${dragOverId === m.id ? 'bg-[#38bdac]/10' : ''} ${m.isPinned ? 'bg-amber-500/5' : ''}`}
+ >
+
+
+
+ {idx + 1}
+
+
+ {avatarSrc(m.avatar) ? (
+
})
{ (e.target as HTMLImageElement).style.display = 'none' }} />
+ ) : (
+
{m.name?.[0] || '创'}
+ )}
+
+
+ {m.vipRole && {m.vipRole}}
+
+
+
+
+
+
+
+
+
+ {m.clickCount ?? 0}
+
+ {!hasPlan ? (
+ —
+ ) : !m.personId ? (
+ 已配置
+ ) : (
+
+ {(() => {
+ const h = planHealthByPerson[m.personId]
+ if (!h) {
+ return (
+
+ 未检测
+
+ )
+ }
+ if (h.status === 'online') {
+ return 在线
+ }
+ if (h.status === 'error') {
+ return (
+
+ 异常
+
+ )
+ }
+ return 未知
+ })()}
+
+
+ )}
+
+
+
+
+ {m.personId &&
}
+ {m.personId &&
}
+
+
+
+ {ex && (
+
+
+ { setRoleTarget(m); setRoleInput(m.vipRole || ''); setShowRoleModal(true) }}
+ onEditSort={() => { setSortTarget(m); setSortInput(m.vipSort != null ? String(m.vipSort) : ''); setShowSortModal(true) }}
+ onEditWebhook={() => { setWebhookTarget(m); setWebhookInput(m.webhookUrl || ''); setShowWebhookModal(true) }}
+ />
+
+
+ )}
+
+ )
+ })}
+
+
+
+
+ )}
+
+ {/* 标签弹窗 */}
+
+
+ {/* 排序弹窗 */}
+
+
+ {/* Webhook 弹窗 */}
+
+
+ {/* 添加/编辑@人物 */}
+
+
+ {/* 删除确认 */}
+
+
+ {/* 用户详情弹窗 */}
+
setUserDetailId(null)} userId={userDetailId ?? null} onUserUpdated={load} />
+
+ {/* 获客详情弹窗 */}
+
+
+ )
+}
+
+/** 展开面板:Token 信息 + 首页入口配置(打赏 + 上麦) */
+function ExpandedPanel({ member: m, onSaved, onCopyToken, onEditRole, onEditSort, onEditWebhook }: {
+ member: VipMember; onSaved: () => void; onCopyToken: (t: string) => void
+ onEditRole: () => void; onEditSort: () => void; onEditWebhook: () => void
+}) {
+ const cfg = m.homeEntryConfig || {}
+ const rw = cfg.linkKaruoReward || {}
+ const sch = cfg.liveMicSchedule || {}
+
+ const [rewardEnabled, setRewardEnabled] = useState(normBool(rw.enabled))
+ const [entryText, setEntryText] = useState(rw.entryButtonText || '支持一下')
+ const [modalTitle, setModalTitle] = useState(rw.modalTitle || '打赏支持')
+ const [modalSub, setModalSub] = useState(rw.modalSubtitle || '感谢支持,任选礼物与数量')
+ const [payBtn, setPayBtn] = useState(rw.payButtonText || '立即支付')
+ const [afterToast, setAfterToast] = useState(rw.afterPayToast || '支付成功,感谢支持!')
+ const [gifts, setGifts] = useState(
+ Array.isArray(rw.gifts) && rw.gifts.length > 0
+ ? rw.gifts.map((g: any) => ({ id: String(g.id || ''), name: String(g.name || ''), price: Number(g.price) || 1, emoji: String(g.emoji || '🎁'), icon: String(g.icon || '') }))
+ : [...DEFAULT_GIFTS],
+ )
+ const [micOn, setMicOn] = useState(normBool(sch.enabled))
+ const [micStart, setMicStart] = useState(sch.micStart || '18:00')
+ const [micEnd, setMicEnd] = useState(sch.micEnd || '21:00')
+ const [micEmoji, setMicEmoji] = useState(sch.micEmoji || '🎤')
+ const [micBtn, setMicBtn] = useState(sch.micButtonText || '申请上麦')
+ const [micWh, setMicWh] = useState(sch.paidWebhookUrl || '')
+ const [saving, setSaving] = useState(false)
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ const hec: HomeEntryConfig = {
+ linkKaruoReward: { enabled: rewardEnabled, entryButtonText: entryText, modalTitle, modalSubtitle: modalSub, payButtonText: payBtn, afterPayToast: afterToast, gifts: gifts.map(g => ({ id: g.id, name: g.name, price: g.price, emoji: g.emoji, icon: g.icon })) },
+ liveMicSchedule: { enabled: micOn, micStart, micEnd, micEmoji, micButtonText: micBtn, paidWebhookUrl: micWh.trim() },
+ }
+ const r = await put<{ success?: boolean; error?: string }>('/api/db/persons/home-entry-config', { userId: m.id, homeEntryConfig: hec })
+ if (!r?.success) { toast.error(r?.error || '保存失败'); return }
+ toast.success('首页入口配置已保存')
+ onSaved()
+ } catch { toast.error('保存失败') } finally { setSaving(false) }
+ }
+
+ if (!m.personId) {
+ return 该会员尚未关联@人物,首页入口配置不可用。
+ }
+
+ return (
+
+ {/* 快捷操作行 + Token */}
+
+
+ Token:
+ {m.personToken}
+
+ {m.isPinned && 当前置顶}
+ {m.personSource === 'vip_sync' && 同步}
+
+
+
+
+
+
+
+
+
+ {/* 打赏 */}
+
+
+ 打赏弹窗
+
+
+ {rewardEnabled && (
+
+
+
+
+
+
+
+
+
+
+
+
+ 礼物列表
+
+
+ {gifts.map((g, i) => (
+
+ setGifts(r => r.map((x, j) => j === i ? { ...x, emoji: e.target.value } : x))} />
+ setGifts(r => r.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
+ setGifts(r => r.map((x, j) => j === i ? { ...x, price: parseFloat(e.target.value) || 0 } : x))} />
+
+
+ ))}
+
+
+ )}
+
+
+ {/* 上麦 */}
+
+
+ 🎤 直播时段 · 上麦
+
+
+ {micOn && (
+
+
+
+
+
+
+
+
+ 上麦支付 Webhook(可选)
+ setMicWh(e.target.value)} />
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+function Fld({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
+ return (
+
+ {label}
+ onChange(e.target.value)} placeholder={placeholder} />
+
+ )
+}
diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx
index 981b562d..06205d40 100644
--- a/soul-admin/src/pages/users/UsersPage.tsx
+++ b/soul-admin/src/pages/users/UsersPage.tsx
@@ -1,6 +1,5 @@
import toast from '@/utils/toast'
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { normalizeImageUrl } from '@/lib/utils'
+import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
@@ -38,15 +37,12 @@ import {
BookOpen,
Settings,
PenLine,
- Navigation,
TrendingUp,
ArrowUpDown,
ChevronDown,
ChevronUp,
Crown,
- Tag,
Zap,
- UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { MbtiAvatarsManager } from '@/components/modules/mbti/MbtiAvatarsManager'
@@ -54,6 +50,7 @@ import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
import { get, del, post, put } from '@/api/client'
+import { SuperIndividualTab } from '@/pages/users/SuperIndividualTab'
interface User {
id: string
@@ -145,19 +142,6 @@ function normalizeUserRule(r: UserRule): UserRule {
return { ...r, triggerConditions: normalizeTriggerConditions(r.triggerConditions) }
}
-interface VipMember {
- id: string
- name: string
- avatar?: string | null
- mbti?: string | null
- vipRole?: string | null
- vipSort?: number | null
- /** 首页超级个体卡片点击次数(/api/db/vip-members 聚合 user_tracks) */
- clickCount?: number | null
- /** 绑定人物后的去重获客人数 */
- leadCount?: number | null
-}
-
/** 与后端 admin_rfm.go calcRFMLevel 一致;列表 RFM 列即「Are you good」分层 */
const RFM_LEVEL_RULES: { level: string; range: string; label: string }[] = [
{ level: 'S', range: '≥85', label: '高价值' },
@@ -167,17 +151,6 @@ const RFM_LEVEL_RULES: { level: string; range: string; label: string }[] = [
{ level: 'D', range: '<30', label: '待激活' },
]
-// 用户旅程阶段定义
-const JOURNEY_STAGES = [
- { id: 'register', label: '注册/登录', icon: '👤', color: 'bg-blue-500/20 border-blue-500/40 text-blue-400', desc: '微信授权登录或手机号注册' },
- { id: 'browse', label: '浏览章节', icon: '📖', color: 'bg-purple-500/20 border-purple-500/40 text-purple-400', desc: '点击免费/付费章节预览' },
- { id: 'bind_phone', label: '绑定手机', icon: '📱', color: 'bg-cyan-500/20 border-cyan-500/40 text-cyan-400', desc: '触发付费章节后绑定手机' },
- { id: 'first_pay', label: '首次付款', icon: '💳', color: 'bg-green-500/20 border-green-500/40 text-green-400', desc: '购买单章或全书' },
- { id: 'fill_profile', label: '完善资料', icon: '✍️', color: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400', desc: '填写头像、MBTI、行业等' },
- { id: 'match', label: '派对房匹配', icon: '🤝', color: 'bg-orange-500/20 border-orange-500/40 text-orange-400', desc: '参与 Soul 派对房' },
- { id: 'vip', label: '升级 VIP', icon: '👑', color: 'bg-amber-500/20 border-amber-500/40 text-amber-400', desc: '付款 ¥1980 购买全书' },
- { id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' },
-]
function confirmDangerousDelete(entity: string): boolean {
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
@@ -185,73 +158,13 @@ function confirmDangerousDelete(entity: string): boolean {
return verifyText === '删除'
}
-/** 获客列表:头像 + 昵称,有 userId 时可点进用户详情 */
-function LeadUserNickCell({
- userId,
- userAvatar,
- nickname,
- name,
- onOpenDetail,
-}: {
- userId?: string
- userAvatar?: string
- nickname?: string
- name?: string
- onOpenDetail: (id: string) => void
-}) {
- const [imgFailed, setImgFailed] = useState(false)
- const label = nickname || name || '-'
- const initial = (label === '-' ? '?' : label).charAt(0)
- const showImg = !!userAvatar?.trim() && !imgFailed
-
- return (
-
-
- {showImg ? (
-
})
setImgFailed(true)}
- />
- ) : (
-
{initial}
- )}
-
-
-
- )
-}
-
-function prettyJson(raw: string): string {
- const s = (raw || '').trim()
- if (!s) return ''
- try {
- return JSON.stringify(JSON.parse(s), null, 2)
- } catch {
- return s
- }
-}
-
export function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams()
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
const rawTabParam = searchParams.get('tab') || 'users'
- const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users'
- const leadActionParam = (searchParams.get('leadAction') || '').trim()
+ const tabParam = ['users', 'rules', 'vip-roles'].includes(rawTabParam)
+ ? rawTabParam
+ : 'users'
// ===== 用户列表 state =====
const [users, setUsers] = useState([])
@@ -296,261 +209,8 @@ export function UsersPage() {
const [editingRule, setEditingRule] = useState(null)
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', triggerConditions: [] as string[], actionType: 'popup', sort: 0, enabled: true })
- // ===== 超级个体(VIP 用户列表) =====
- const [vipMembers, setVipMembers] = useState([])
- const [vipMembersLoading, setVipMembersLoading] = useState(false)
- const [draggingVipId, setDraggingVipId] = useState(null)
- const [dragOverVipId, setDragOverVipId] = useState(null)
-
- // ===== 用户旅程总览 =====
- const [journeyStats, setJourneyStats] = useState>({})
- const [journeyLoading, setJourneyLoading] = useState(false)
- const [journeyStage, setJourneyStage] = useState(null)
- const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([])
- const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
- const [trackUserId, setTrackUserId] = useState(null)
- const [trackUserNick, setTrackUserNick] = useState('')
- const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([])
- const [userTracksLoading, setUserTracksLoading] = useState(false)
const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState>({})
- // ===== 获客列表(存客宝) =====
- const [leadsRecords, setLeadsRecords] = useState<{
- id: number
- userId?: string
- userNickname?: string
- /** 与会员资料一致,来自 users.avatar(接口已 resolve) */
- userAvatar?: string
- phone?: string
- wechatId?: string
- name?: string
- source?: string
- planApiKey?: string
- personName?: string
- pushStatus?: 'pending' | 'success' | 'failed' | string
- retryCount?: number
- ckbCode?: number
- ckbMessage?: string
- ckbData?: string
- ckbError?: string
- lastPushAt?: string
- nextRetryAt?: string
- createdAt?: string
- }[]>([])
- const [leadsTotal, setLeadsTotal] = useState(0)
- const [leadsPage, setLeadsPage] = useState(1)
- const [leadsPageSize] = useState(10)
- const [leadsLoading, setLeadsLoading] = useState(false)
- const [leadsError, setLeadsError] = useState(null)
- const [leadsSearchTerm, setLeadsSearchTerm] = useState('')
- const debouncedLeadsSearch = useDebounce(leadsSearchTerm, 300)
- const [leadsSourceFilter, setLeadsSourceFilter] = useState('')
- const [leadsActionFilter, setLeadsActionFilter] = useState('')
- const [leadsPushStatusFilter, setLeadsPushStatusFilter] = useState('')
- const [leadsDedupEnabled, setLeadsDedupEnabled] = useState(false)
- const [leadsStats, setLeadsStats] = useState<{ uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }>({})
- const [retryingLeadId, setRetryingLeadId] = useState(null)
- const [deletingLeadId, setDeletingLeadId] = useState(null)
- const [leadSelectedIds, setLeadSelectedIds] = useState([])
- const [batchDeletingLeads, setBatchDeletingLeads] = useState(false)
- const leadsHeaderCheckboxRef = useRef(null)
- const [batchRetrying, setBatchRetrying] = useState(false)
- const [showCkbDataDialog, setShowCkbDataDialog] = useState(false)
- const [ckbDataDialogTitle, setCkbDataDialogTitle] = useState('存客宝返回 data')
- const [ckbDataDialogContent, setCkbDataDialogContent] = useState('')
- const loadLeads = useCallback(async (searchVal?: string, sourceVal?: string) => {
- setLeadsLoading(true)
- setLeadsError(null)
- try {
- const params = new URLSearchParams({
- mode: 'contact',
- page: String(leadsPage),
- pageSize: String(leadsPageSize),
- })
- const s = searchVal ?? debouncedLeadsSearch
- if (s) params.set('search', s)
- const src = sourceVal ?? leadsSourceFilter
- if (src) params.set('source', src)
- if (leadsActionFilter) params.set('action', leadsActionFilter)
- if (leadsPushStatusFilter) params.set('pushStatus', leadsPushStatusFilter)
- const data = await get<{
- success?: boolean; records?: unknown[]; total?: number;
- stats?: { uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }
- error?: string
- }>(`/api/db/ckb-leads?${params}`)
- if (data?.success) {
- setLeadsRecords((data.records || []) as typeof leadsRecords)
- setLeadsTotal(data.total ?? 0)
- if (data.stats) setLeadsStats(data.stats)
- } else {
- const msg = data?.error || '加载获客列表失败'
- setLeadsError(msg)
- toast.error(msg)
- setLeadsRecords([])
- setLeadsTotal(0)
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : '网络错误'
- setLeadsError(msg)
- toast.error('加载获客列表失败: ' + msg)
- setLeadsRecords([])
- setLeadsTotal(0)
- } finally {
- setLeadsLoading(false)
- }
- }, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter])
-
- useEffect(() => {
- setLeadSelectedIds([])
- }, [debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter])
-
- // URL 同步:只在获客列表 Tab 时读取 leadAction,默认空=全部类型
- useEffect(() => {
- if (tabParam !== 'leads') return
- setLeadsActionFilter(leadActionParam)
- }, [tabParam, leadActionParam])
-
- type LeadRetryRecordPatch = {
- pushStatus?: string
- retryCount?: number
- ckbCode?: number
- ckbMessage?: string
- ckbData?: string
- ckbError?: string
- lastPushAt?: string | null
- nextRetryAt?: string | null
- }
-
- function mergeLeadRowAfterRetry(row: (typeof leadsRecords)[0], rec: LeadRetryRecordPatch) {
- return {
- ...row,
- ...(rec.pushStatus !== undefined ? { pushStatus: rec.pushStatus } : {}),
- ...(typeof rec.retryCount === 'number' ? { retryCount: rec.retryCount } : {}),
- ...(typeof rec.ckbCode === 'number' ? { ckbCode: rec.ckbCode } : {}),
- ...(rec.ckbMessage !== undefined ? { ckbMessage: rec.ckbMessage } : {}),
- ...(rec.ckbData !== undefined ? { ckbData: rec.ckbData } : {}),
- ...(rec.ckbError !== undefined ? { ckbError: rec.ckbError } : {}),
- ...(rec.lastPushAt !== undefined ? { lastPushAt: rec.lastPushAt ?? undefined } : {}),
- ...(rec.nextRetryAt !== undefined ? { nextRetryAt: rec.nextRetryAt ?? undefined } : {}),
- }
- }
-
- async function retryLeadPush(recordId: number) {
- if (!recordId) return
- setRetryingLeadId(recordId)
- try {
- const data = await post<{
- success?: boolean
- pushed?: boolean
- error?: string
- record?: LeadRetryRecordPatch
- }>('/api/db/ckb-leads/retry', { id: recordId })
- if (data?.success) {
- toast.success(data.pushed ? '重推成功' : '已发起重推,请刷新查看状态')
- if (data.record) {
- setLeadsRecords((prev) =>
- prev.map((row) =>
- row.id === recordId ? mergeLeadRowAfterRetry(row, data.record!) : row,
- ),
- )
- }
- } else {
- toast.error(data?.error || '重推失败')
- }
- } catch (e) {
- toast.error(e instanceof Error ? e.message : '重推请求失败')
- } finally {
- setRetryingLeadId(null)
- }
- }
-
- async function deleteLeadRecord(recordId: number) {
- if (!recordId) return
- if (!confirm('确定删除该条获客记录?删除后不可恢复。')) return
- setDeletingLeadId(recordId)
- try {
- const data = await post<{ success?: boolean; error?: string }>('/api/db/ckb-leads/delete', { id: recordId })
- if (data?.success) {
- toast.success('已删除')
- } else {
- toast.error(data?.error || '删除失败')
- }
- } catch (e) {
- toast.error(e instanceof Error ? e.message : '删除请求失败')
- } finally {
- setDeletingLeadId(null)
- loadLeads()
- }
- }
-
- async function retryFailedLeadsInPage() {
- const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
- if (failedRows.length === 0) {
- toast.info('当前页无失败记录')
- return
- }
- setBatchRetrying(true)
- let successCount = 0
- for (const row of failedRows) {
- try {
- const data = await post<{
- success?: boolean
- pushed?: boolean
- record?: LeadRetryRecordPatch
- }>('/api/db/ckb-leads/retry', { id: row.id })
- if (data?.success && data.pushed) successCount++
- if (data?.success && data.record) {
- const patch = data.record
- setLeadsRecords((prev) =>
- prev.map((r) => (r.id === row.id ? mergeLeadRowAfterRetry(r, patch) : r)),
- )
- }
- } catch {
- // 单条失败继续下一条,避免中断整批重推
- }
- }
- setBatchRetrying(false)
- const total = failedRows.length
- toast.success(`批量重推完成:成功 ${successCount} / ${total}`)
- }
-
- function exportFailedLeadsCsv() {
- const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
- if (failedRows.length === 0) {
- toast.info('当前筛选下无失败记录可导出')
- return
- }
- const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`
- const headers = ['ID', '昵称', '手机号', '微信号', '对应@人', '计划Key', '来源', '推送状态', '重试次数', '失败原因', '下次重试时间', '创建时间']
- const lines = [headers.join(',')]
- for (const r of failedRows) {
- lines.push([
- esc(r.id),
- esc(r.userNickname || r.name || ''),
- esc(r.phone || ''),
- esc(r.wechatId || ''),
- esc(r.personName || ''),
- esc(r.planApiKey || ''),
- esc(r.source || ''),
- esc(r.pushStatus || ''),
- esc(typeof r.retryCount === 'number' ? r.retryCount : ''),
- esc(r.ckbError || ''),
- esc(r.nextRetryAt ? new Date(r.nextRetryAt).toLocaleString() : ''),
- esc(r.createdAt ? new Date(r.createdAt).toLocaleString() : ''),
- ].join(','))
- }
- const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `获客失败清单-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- toast.success(`已导出失败清单(${failedRows.length} 条)`)
- }
-
const loadMbtiAvatarsMap = useCallback(async () => {
try {
const data = await get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars')
@@ -560,17 +220,6 @@ export function UsersPage() {
setMbtiAvatarsMap({})
}
}, [])
- useEffect(() => {
- if (searchParams.get('tab') === 'leads') loadLeads()
- }, [searchParams.get('tab'), leadsPage, loadLeads])
-
- useEffect(() => {
- if (tabParam !== 'leads') return
- const timer = window.setInterval(() => {
- loadLeads()
- }, 30000)
- return () => window.clearInterval(timer)
- }, [tabParam, loadLeads])
useEffect(() => {
loadMbtiAvatarsMap()
@@ -797,196 +446,6 @@ export function UsersPage() {
try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { }
}
- // ===== 超级个体(VIP 用户列表) =====
- const loadVipMembers = useCallback(async () => {
- setVipMembersLoading(true)
- try {
- const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>(
- '/api/db/vip-members?limit=500',
- )
- if (data?.success && data.data) {
- const list = [...data.data].map((m, idx) => ({
- ...m,
- vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
- }))
- list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
- setVipMembers(list)
- } else if (data && data.error) {
- toast.error(data.error)
- }
- } catch {
- toast.error('加载超级个体列表失败')
- } finally {
- setVipMembersLoading(false)
- }
- }, [])
-
- const [showVipRoleModal, setShowVipRoleModal] = useState(false)
- const [vipRoleModalMember, setVipRoleModalMember] = useState(null)
- const [vipRoleInput, setVipRoleInput] = useState('')
- const [vipRoleSaving, setVipRoleSaving] = useState(false)
- const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
-
- const openVipRoleModal = (member: VipMember) => {
- setVipRoleModalMember(member)
- setVipRoleInput(member.vipRole || '')
- setShowVipRoleModal(true)
- }
-
- const handleSetVipRole = async (value: string) => {
- const trimmed = value.trim()
- if (!vipRoleModalMember) return
- if (!trimmed) {
- toast.error('请选择或输入标签')
- return
- }
- setVipRoleSaving(true)
- try {
- const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
- id: vipRoleModalMember.id,
- vipRole: trimmed,
- })
- if (!res?.success) {
- toast.error(res?.error || '更新超级个体标签失败')
- return
- }
- toast.success('已更新超级个体标签')
- setShowVipRoleModal(false)
- setVipRoleModalMember(null)
- await loadVipMembers()
- } catch {
- toast.error('更新超级个体标签失败')
- } finally {
- setVipRoleSaving(false)
- }
- }
-
- const [showVipSortModal, setShowVipSortModal] = useState(false)
- const [vipSortModalMember, setVipSortModalMember] = useState(null)
- const [vipSortInput, setVipSortInput] = useState('')
- const [vipSortSaving, setVipSortSaving] = useState(false)
-
- const openVipSortModal = (member: VipMember) => {
- setVipSortModalMember(member)
- setVipSortInput(member.vipSort != null ? String(member.vipSort) : '')
- setShowVipSortModal(true)
- }
-
- const handleSetVipSort = async () => {
- if (!vipSortModalMember) return
- const num = Number(vipSortInput)
- if (!Number.isFinite(num)) {
- toast.error('请输入有效的数字序号')
- return
- }
- setVipSortSaving(true)
- try {
- const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
- id: vipSortModalMember.id,
- vipSort: num,
- })
- if (!res?.success) {
- toast.error(res?.error || '更新排序序号失败')
- return
- }
- toast.success('已更新排序序号')
- setShowVipSortModal(false)
- setVipSortModalMember(null)
- await loadVipMembers()
- } catch {
- toast.error('更新排序序号失败')
- } finally {
- setVipSortSaving(false)
- }
- }
-
- const handleVipRowDragStart = (e: React.DragEvent, id: string) => {
- e.dataTransfer.effectAllowed = 'move'
- e.dataTransfer.setData('text/plain', id)
- setDraggingVipId(id)
- }
-
- const handleVipRowDragOver = (e: React.DragEvent, id: string) => {
- e.preventDefault()
- if (dragOverVipId !== id) setDragOverVipId(id)
- }
-
- const handleVipRowDragEnd = () => {
- setDraggingVipId(null)
- setDragOverVipId(null)
- }
-
- const handleVipRowDrop = async (e: React.DragEvent, targetId: string) => {
- e.preventDefault()
- const fromId = e.dataTransfer.getData('text/plain') || draggingVipId
- setDraggingVipId(null)
- setDragOverVipId(null)
- if (!fromId || fromId === targetId) return
-
- const fromMember = vipMembers.find((m) => m.id === fromId)
- const targetMember = vipMembers.find((m) => m.id === targetId)
- if (!fromMember || !targetMember) return
-
- const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1
- const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1
-
- // 本地先交换顺序,提升交互流畅度
- setVipMembers((prev) => {
- const list = [...prev]
- const fromIdx = list.findIndex((m) => m.id === fromId)
- const toIdx = list.findIndex((m) => m.id === targetId)
- if (fromIdx === -1 || toIdx === -1) return prev
- const next = [...list]
- const [m1, m2] = [next[fromIdx], next[toIdx]]
- next[fromIdx] = { ...m2, vipSort: fromSort }
- next[toIdx] = { ...m1, vipSort: targetSort }
- return next
- })
-
- try {
- const [res1, res2] = await Promise.all([
- put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }),
- put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }),
- ])
- if (!res1?.success || !res2?.success) {
- toast.error(res1?.error || res2?.error || '更新排序失败')
- await loadVipMembers()
- return
- }
- toast.success('已更新排序')
- await loadVipMembers()
- } catch {
- toast.error('更新排序失败')
- await loadVipMembers()
- }
- }
-
- // ===== 用户旅程总览 =====
- const loadJourneyStats = useCallback(async () => {
- setJourneyLoading(true)
- try {
- const data = await get<{ success?: boolean; stats?: Record }>('/api/db/users/journey-stats')
- if (data?.success && data.stats) setJourneyStats(data.stats)
- } catch { } finally { setJourneyLoading(false) }
- }, [])
- const loadJourneyUsers = useCallback(async (stage: string) => {
- setJourneyStage(stage)
- setJourneyUsersLoading(true)
- try {
- const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`)
- if (data?.success && data.users) setJourneyUsers(data.users)
- } catch { } finally { setJourneyUsersLoading(false) }
- }, [])
- const loadUserTracks = useCallback(async (userId: string, nick: string) => {
- setTrackUserId(userId)
- setTrackUserNick(nick)
- setUserTracksLoading(true)
- try {
- const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`)
- if (data?.success && data.tracks) setUserTracks(data.tracks)
- } catch { } finally { setUserTracksLoading(false) }
- }, [])
-
// ===== 批量用户补全 =====
const [batchEnrichLoading, setBatchEnrichLoading] = useState(false)
const handleBatchEnrich = async () => {
@@ -1015,164 +474,6 @@ export function UsersPage() {
return Math.round((filled / fields.length) * 100)
}
- /** 展示列表:默认不去重(按原始记录展示);可选开启去重用于运营查看「按人合并」 */
- const { leadsRows, leadsRawCount, leadsDeduped } = useMemo(() => {
- const normalizePhone = (p?: string | null) => (p || '').replace(/\D/g, '') || ''
- const dedupKey = (r: (typeof leadsRecords)[0]) => {
- const phone = normalizePhone(r.phone)
- if (phone) return `phone:${phone}`
- const uid = (r.userId || '').trim()
- if (uid) return `user:${uid}`
- const wx = (r.wechatId || '').trim()
- if (wx) return `wechat:${wx}`
- return `row:${r.id}`
- }
- const q = debouncedLeadsSearch.trim().toLowerCase()
- let rows = leadsRecords
- if (q) {
- rows = leadsRecords.filter((r) => {
- const blob = [r.userNickname, r.name, r.phone, r.wechatId, r.personName, r.source, r.planApiKey]
- .filter(Boolean)
- .join(' ')
- .toLowerCase()
- return blob.includes(q)
- })
- }
- const sorted = [...rows].sort((a, b) => {
- const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0
- const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0
- return tb - ta
- })
- if (!leadsDedupEnabled) {
- return { leadsRows: sorted, leadsRawCount: rows.length, leadsDeduped: 0 }
- }
- const seen = new Set()
- const out: typeof leadsRecords = []
- for (const r of sorted) {
- const k = dedupKey(r)
- if (seen.has(k)) continue
- seen.add(k)
- out.push(r)
- }
- return { leadsRows: out, leadsRawCount: rows.length, leadsDeduped: rows.length - out.length }
- }, [leadsRecords, debouncedLeadsSearch, leadsDedupEnabled])
-
- useEffect(() => {
- const pageIds = leadsRows.map((r) => r.id)
- const n = pageIds.filter((id) => leadSelectedIds.includes(id)).length
- const el = leadsHeaderCheckboxRef.current
- if (el) {
- el.indeterminate = n > 0 && n < pageIds.length
- }
- }, [leadsRows, leadSelectedIds])
-
- function toggleLeadSelectAllOnPage() {
- const pageIds = leadsRows.map((r) => r.id)
- const allOn = pageIds.length > 0 && pageIds.every((id) => leadSelectedIds.includes(id))
- if (allOn) {
- setLeadSelectedIds((prev) => prev.filter((id) => !pageIds.includes(id)))
- } else {
- setLeadSelectedIds((prev) => [...new Set([...prev, ...pageIds])])
- }
- }
-
- function toggleLeadOne(id: number) {
- setLeadSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
- }
-
- async function batchDeleteLeadRecords() {
- if (leadSelectedIds.length === 0) {
- toast.info('请先勾选要删除的记录')
- return
- }
- const n = leadSelectedIds.length
- if (!confirm(`确定批量删除选中的 ${n} 条获客记录?删除后不可恢复。`)) return
- const CHUNK = 500
- setBatchDeletingLeads(true)
- try {
- let totalDeleted = 0
- for (let i = 0; i < leadSelectedIds.length; i += CHUNK) {
- const slice = leadSelectedIds.slice(i, i + CHUNK)
- const data = await post<{ success?: boolean; deleted?: number; error?: string }>(
- '/api/db/ckb-leads/delete-batch',
- { ids: slice },
- )
- if (!data?.success) {
- toast.error(data?.error || '批量删除失败')
- return
- }
- totalDeleted += Number(data.deleted) || 0
- }
- toast.success(`已删除 ${totalDeleted} 条`)
- setLeadSelectedIds([])
- } catch (e) {
- toast.error(e instanceof Error ? e.message : '批量删除请求失败')
- } finally {
- setBatchDeletingLeads(false)
- loadLeads()
- }
- }
-
- const pushStatusBadge = (status?: string) => {
- const s = status || ''
- if (s === 'success')
- return 已推送(存客宝已接收)
- if (s === 'pending_verify')
- return 待通过 / 处理中
- if (s === 'expired') return 已过期
- if (s === 'failed') return 失败
- if (s === 'pending')
- return 待推送
- if (s)
- return (
-
- {s}
-
- )
- return 待推送
- }
-
- const failedReasonStats = useMemo(() => {
- const map = new Map()
- for (const r of leadsRows) {
- if (r.pushStatus !== 'failed') continue
- const reason = (r.ckbError || '未知错误').trim() || '未知错误'
- map.set(reason, (map.get(reason) || 0) + 1)
- }
- return Array.from(map.entries())
- .map(([reason, count]) => ({ reason, count }))
- .sort((a, b) => b.count - a.count)
- }, [leadsRows])
-
- async function copyFailedDebugInfo() {
- const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
- if (failedRows.length === 0) {
- toast.info('当前页无失败记录')
- return
- }
- const topReasons = failedReasonStats.slice(0, 8).map((x) => `- ${x.reason}:${x.count} 条`).join('\n')
- const recentIds = failedRows.slice(0, 30).map((r) => r.id).join(', ')
- const detailRows = failedRows.slice(0, 20).map((r) => (
- `#${r.id} | ${r.userNickname || r.name || '-'} | 手机:${r.phone || '-'} | 来源:${r.source || '-'} | 重试:${r.retryCount ?? 0} | 错误:${r.ckbError || '-'}`
- )).join('\n')
- const text = [
- `【获客失败排障信息】`,
- `时间:${new Date().toLocaleString()}`,
- `当前页失败总数:${failedRows.length}`,
- `主要失败原因:`,
- topReasons || '- 无',
- `最近失败记录ID(最多30条):${recentIds || '无'}`,
- '',
- `失败记录明细(最多20条):`,
- detailRows || '无',
- ].join('\n')
- try {
- await navigator.clipboard.writeText(text)
- toast.success('已复制排障信息')
- } catch {
- toast.error('复制失败,请检查浏览器剪贴板权限')
- }
- }
return (
@@ -1183,7 +484,7 @@ export function UsersPage() {
)}
-
+
用户管理
@@ -1192,81 +493,61 @@ export function UsersPage() {
{rfmSortMode && ' · RFM 排序中'}
-
-
-
-
-
+
+
+
+
+
+ {rfmAlgoOpen && (
+
+
+
+ 综合分 0–100(六维度):最近消费 R(25%)+ 订单频次 F(20%)+ 累计金额 M(20%)+ 推荐人数(15%)+ 行为轨迹(10%)+ 资料完善(10%)。各维度在全量用户中归一化,与后端{' '}
+ /api/db/users/rfm 一致。
+
+
+ {RFM_LEVEL_RULES.map(({ level, range, label }) => (
+
+ {level} {range} · {label}
+
+ ))}
- {rfmAlgoOpen && (
- <>
-
- 综合分 0–100(六维度):最近消费 R(25%)+ 订单频次 F(20%)+ 累计金额 M(20%)+ 推荐人数(15%)+ 行为轨迹(10%)+ 资料完善(10%)。各维度在全量用户中归一化,与后端{' '}
- /api/db/users/rfm 一致。
-
-
- {RFM_LEVEL_RULES.map(({ level, range, label }) => (
-
- {level} {range} · {label}
-
- ))}
-
- >
- )}
-
+ )}
{ const sp = new URLSearchParams(searchParams); if (v === 'users') sp.delete('tab'); else sp.set('tab', v); setSearchParams(sp) }} className="w-full">
用户列表
- loadLeads()}>
- 获客列表
-
-
- 用户旅程总览
-
规则配置
-
- 超级个体列表
+
+ 超级个体
@@ -1285,11 +566,15 @@ export function UsersPage() {