feat: 同步本地三端改动并清理上传凭证风险
整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。 Made-with: Cursor
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-right" wx:if="{{!auditMode && homePinnedPerson && homePinnedPerson.token}}">
|
||||
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
|
||||
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
|
||||
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
|
||||
<!-- 右上角单一入口:由置顶超级个体决定,点击弹出横排礼物,点选即付 -->
|
||||
<view class="header-right" wx:if="{{!auditMode && headerCornerAction.show}}">
|
||||
<view class="contact-btn header-corner-btn" catchtap="onHeaderCornerTap" hover-class="none">
|
||||
<image wx:if="{{headerCornerAction.avatar}}" class="contact-avatar" src="{{headerCornerAction.avatar}}" mode="aspectFill"/>
|
||||
<text wx:elif="{{headerCornerAction.emoji}}" class="mic-entry-emoji">{{headerCornerAction.emoji}}</text>
|
||||
<text class="contact-name">{{headerCornerAction.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -161,6 +163,32 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 横排礼物弹层(上麦 / 打赏统一,与「申请上麦」截图一致,点选即付) -->
|
||||
<view class="tip-modal-mask" wx:if="{{showTipModal}}" catchtap="closeTipModal">
|
||||
<view class="tip-modal-panel tip-modal-panel--mic" catchtap="stopTipModalBubble">
|
||||
<view class="mic-modal-head">
|
||||
<text class="mic-modal-title">{{tipModalTitle}}</text>
|
||||
<view class="mic-modal-x" catchtap="closeTipModal">×</view>
|
||||
</view>
|
||||
<text class="mic-modal-desc" wx:if="{{tipModalSubtitle}}">{{tipModalSubtitle}}</text>
|
||||
<view class="mic-gift-row-h" wx:if="{{micHorizontalSlots.length > 0}}">
|
||||
<view
|
||||
wx:for="{{micHorizontalSlots}}"
|
||||
wx:key="id"
|
||||
class="mic-gift-card {{micDefaultGiftId === item.id ? 'mic-gift-card--default' : ''}} {{item.micIsCenterSlot ? 'mic-gift-card--mid' : ''}}"
|
||||
data-id="{{item.id}}"
|
||||
catchtap="onHorizontalTipPay"
|
||||
>
|
||||
<view class="mic-gift-default-tag" wx:if="{{micDefaultGiftId === item.id}}">默认</view>
|
||||
<image wx:if="{{item.micShowIcon}}" class="mic-gift-icon-img" src="{{item.icon}}" mode="aspectFit" style="width: {{item.micEmojiRpx}}rpx; height: {{item.micEmojiRpx}}rpx;"/>
|
||||
<text wx:else class="mic-gift-emoji" style="font-size: {{item.micEmojiRpx}}rpx;">{{item.emoji}}</text>
|
||||
<text class="mic-gift-name">{{item.name}}</text>
|
||||
<text class="mic-gift-price">¥{{item.price}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
<view class="bottom-space"></view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 判断免费
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
|
||||
<text class="paywall-title">{{readUi.fullUnlockTitle || '加入365读书会'}}</text>
|
||||
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
|
||||
<text class="paywall-desc">{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}}</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
@@ -170,7 +170,7 @@
|
||||
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
|
||||
<text class="login-btn-text">手机号登录后购买</text>
|
||||
</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -245,12 +245,12 @@
|
||||
<text class="paywall-expanded-lead">{{readUi.singlePageExpandedHint || '预览页不能直接付款,务必先点底栏「前往小程序」。'}}</text>
|
||||
<view class="paywall-expanded-divider"></view>
|
||||
<text class="paywall-expanded-line">{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}}</text>
|
||||
<text class="paywall-expanded-line">{{readUi.fullPaywallTip || '转发给需要的人,一起学习还能赚佣金'}}</text>
|
||||
<text class="paywall-expanded-line">{{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
|
||||
<text class="paywall-title">{{readUi.fullUnlockTitle || '加入365读书会'}}</text>
|
||||
<text class="paywall-desc">{{readUi.fullUnlockDesc || '可先上滑阅读预览'}}</text>
|
||||
<text class="paywall-subdesc" wx:if="{{readUi.fullLockedProgressText}}">{{readUi.fullLockedProgressText}}</text>
|
||||
<text class="paywall-subdesc" wx:else>已阅读约 {{effectivePreviewPercent}}% ,购买后继续阅读</text>
|
||||
@@ -259,7 +259,7 @@
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||
</view>
|
||||
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
|
||||
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= fullbookShowThreshold}}">
|
||||
<view class="btn-left">
|
||||
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
|
||||
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
||||
@@ -271,7 +271,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
183
scripts/deploy_kr_btapi_verify.py
Normal file
@@ -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)
|
||||
157
scripts/download_chapter_images_from_md.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从书稿 Markdown 中抓取远程图片( 与 <img src="...">),下载到本地 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'<img[^>]+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()
|
||||
89
scripts/download_mbti_avatars_16p_colored.py
Normal file
@@ -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()
|
||||
59
scripts/download_mbti_avatars_dicebear.py
Normal file
@@ -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()
|
||||
124
scripts/fix_2026_daily_part.py
Normal file
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
29
soul-admin/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="w-6 h-6 border-2 border-[#38bdac] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-2 text-gray-400 text-sm">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="distribution" element={<DistributionPage />} />
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="content" element={<ContentPage />} />
|
||||
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><DashboardPage /></Suspense>} />
|
||||
<Route path="orders" element={<Suspense fallback={<PageLoader />}><OrdersPage /></Suspense>} />
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><UsersPage /></Suspense>} />
|
||||
<Route path="distribution" element={<Suspense fallback={<PageLoader />}><DistributionPage /></Suspense>} />
|
||||
<Route path="withdrawals" element={<Suspense fallback={<PageLoader />}><WithdrawalsPage /></Suspense>} />
|
||||
<Route path="content" element={<Suspense fallback={<PageLoader />}><ContentPage /></Suspense>} />
|
||||
<Route path="referral-settings" element={<Suspense fallback={<PageLoader />}><ReferralSettingsPage /></Suspense>} />
|
||||
<Route path="author-settings" element={<Navigate to="/settings?tab=author" replace />} />
|
||||
<Route path="vip-roles" element={<VipRolesPage />} />
|
||||
<Route path="mentors" element={<MentorsPage />} />
|
||||
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
|
||||
<Route path="vip-roles" element={<Suspense fallback={<PageLoader />}><VipRolesPage /></Suspense>} />
|
||||
<Route path="mentors" element={<Suspense fallback={<PageLoader />}><MentorsPage /></Suspense>} />
|
||||
<Route path="mentor-consultations" element={<Suspense fallback={<PageLoader />}><MentorConsultationsPage /></Suspense>} />
|
||||
<Route path="admin-users" element={<Navigate to="/settings?tab=admin" replace />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
<Route path="qrcodes" element={<QRCodesPage />} />
|
||||
<Route path="find-partner" element={<FindPartnerPage />} />
|
||||
<Route path="match" element={<MatchPage />} />
|
||||
<Route path="match-records" element={<MatchRecordsPage />} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>} />
|
||||
<Route path="payment" element={<Suspense fallback={<PageLoader />}><PaymentPage /></Suspense>} />
|
||||
<Route path="site" element={<Suspense fallback={<PageLoader />}><SitePage /></Suspense>} />
|
||||
<Route path="qrcodes" element={<Suspense fallback={<PageLoader />}><QRCodesPage /></Suspense>} />
|
||||
<Route path="find-partner" element={<Suspense fallback={<PageLoader />}><FindPartnerPage /></Suspense>} />
|
||||
<Route path="match" element={<Suspense fallback={<PageLoader />}><MatchPage /></Suspense>} />
|
||||
<Route path="match-records" element={<Suspense fallback={<PageLoader />}><MatchRecordsPage /></Suspense>} />
|
||||
<Route path="api-doc" element={<Navigate to="/api-docs" replace />} />
|
||||
<Route path="api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="api-docs" element={<Suspense fallback={<PageLoader />}><ApiDocsPage /></Suspense>} />
|
||||
<Route path="open-platform" element={<Navigate to="/settings?tab=open-platform" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -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<T = unknown>(
|
||||
|
||||
1036
soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx
Normal file
@@ -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<Record<string, string>>({})
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||
@@ -84,86 +94,48 @@ export function MbtiAvatarsManager() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-[#38bdac]/25 shadow-xl">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-white flex items-center gap-2 text-lg">
|
||||
<Smile className="w-5 h-5 text-[#38bdac]" />
|
||||
MBTI 头像库
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400 text-sm leading-relaxed">
|
||||
采用人物化风格,按 MBTI 性格自动生成。头像内不显示中英文,仅显示人物形象,颜色与站点主题融合。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" className="bg-[#38bdac] hover:bg-[#2da396]" onClick={generateAll} disabled={generating}>
|
||||
<WandSparkles className="w-3.5 h-3.5 mr-1" />
|
||||
{generating ? '生成中…' : '一键生成16头像'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" className="border-gray-600 text-gray-300" onClick={load}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||||
重新加载
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="bg-emerald-600 hover:bg-emerald-500" onClick={save} disabled={saving}>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
{saving ? '保存中…' : '保存映射'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" className="bg-blue-600 hover:bg-blue-500 text-white" onClick={() => fill16pAvatars('male')}>
|
||||
使用男版
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="bg-pink-600 hover:bg-pink-500 text-white" onClick={() => fill16pAvatars('female')}>
|
||||
使用女版
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="bg-[#38bdac] hover:bg-[#2da396]" onClick={generateAll} disabled={generating}>
|
||||
<WandSparkles className="w-3.5 h-3.5 mr-1" />
|
||||
生成 SVG
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="bg-emerald-600 hover:bg-emerald-500" onClick={save} disabled={saving}>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" className="border-amber-500/50 text-amber-400" onClick={async () => {
|
||||
try {
|
||||
const res = await post<{ success?: boolean; assigned?: number; total?: number; message?: string }>('/api/admin/mbti-avatars/batch-assign', {})
|
||||
if (res?.success) toast.success(`已为 ${res.assigned}/${res.total} 位无头像用户分配默认头像`)
|
||||
else toast.error(res?.message || '分配失败')
|
||||
} catch { toast.error('分配失败') }
|
||||
}}>
|
||||
批量分配给无头像用户
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" className="border-gray-600 text-gray-300" onClick={load}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs">选择风格后保存,无头像用户按 MBTI 自动匹配。前端用户也可在个人资料中选择。</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||
{MBTI_TYPES_ORDERED.map((t) => {
|
||||
const url = avatars[t] ?? ''
|
||||
const meta = MBTI_AVATAR_PROFILES[t]
|
||||
return (
|
||||
<div
|
||||
key={t}
|
||||
className="rounded-xl border border-gray-700/60 bg-[#0a1628] p-3 flex flex-col gap-2 hover:border-[#38bdac]/35 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 font-mono text-xs">{t}</Badge>
|
||||
<span className="text-xs text-gray-400 truncate" title={meta.title}>
|
||||
{meta.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-[#081322] ring-2 ring-[#38bdac]/40 ring-offset-2 ring-offset-[#0a1628]">
|
||||
{url ? (
|
||||
<img src={url} alt={t} className="w-full h-full object-cover scale-110" />
|
||||
) : (
|
||||
<span className="text-gray-600 text-[10px]">未配</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white h-8 text-xs"
|
||||
placeholder="https://... 或 data:image/..."
|
||||
value={url}
|
||||
onChange={(e) => setAvatars((prev) => ({ ...prev, [t]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] border-[#38bdac]/40 text-[#38bdac]"
|
||||
onClick={() => generateOne(t)}
|
||||
>
|
||||
生成这张
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-[11px] text-gray-400"
|
||||
onClick={() => setAvatars((prev) => ({ ...prev, [t]: '' }))}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<div key={t} className="rounded-lg border border-gray-700/50 bg-[#0a1628] p-2 flex flex-col items-center gap-1 hover:border-[#38bdac]/35 transition-colors">
|
||||
<div className="w-10 h-10 rounded-full overflow-hidden flex items-center justify-center bg-[#081322] ring-1 ring-[#38bdac]/30">
|
||||
{url ? <img src={mbtiAvatarImgSrc(url)} alt={t} className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} /> : <span className="text-gray-600 text-[8px]">未配</span>}
|
||||
</div>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 font-mono text-[10px] px-1">{t}</Badge>
|
||||
<span className="text-[9px] text-gray-500 truncate max-w-full" title={meta.nickname}>{meta.nickname}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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<MbtiType, MbtiAvatarProfile> = {
|
||||
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) {
|
||||
|
||||
@@ -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<string, number>,
|
||||
}
|
||||
|
||||
export function ContentPage() {
|
||||
const navigate = useNavigate()
|
||||
const [sectionsList, setSectionsList] = useState<SectionListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||||
@@ -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<PersonItem | null>(null)
|
||||
const [editingPerson, _setEditingPerson] = useState<PersonItem | null>(null)
|
||||
void _setEditingPerson
|
||||
const [personToDelete, setPersonToDelete] = useState<PersonItem | null>(null)
|
||||
/** 超级个体用户 id → 飞书群 Webhook(与 /api/db/vip-members 一致,供 @列表展示与编辑) */
|
||||
const [vipWebhookByUserId, setVipWebhookByUserId] = useState<Record<string, string>>({})
|
||||
const [_vipWebhookByUserId, setVipWebhookByUserId] = useState<Record<string, string>>({})
|
||||
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<Record<string, number>>({})
|
||||
const [_ckbLeadCounts, setCkbLeadCounts] = useState<Record<string, number>>({})
|
||||
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<RichEditorRef>(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() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">最新新增</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newSection.isNew}
|
||||
onChange={(e) => setNewSection({ ...newSection, isNew: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">标记 NEW</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">小程序直推</Label>
|
||||
<div className="flex items-center h-10">
|
||||
@@ -1663,31 +1641,6 @@ export function ContentPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">文章类型</Label>
|
||||
<div className="flex items-center gap-4 h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="new-edition-type"
|
||||
checked={newSection.editionPremium !== true}
|
||||
onChange={() => setNewSection({ ...newSection, editionStandard: true, editionPremium: false })}
|
||||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">普通版</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="new-edition-type"
|
||||
checked={newSection.editionPremium === true}
|
||||
onChange={() => setNewSection({ ...newSection, editionStandard: false, editionPremium: true })}
|
||||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">增值版</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">热度分</Label>
|
||||
<Input
|
||||
@@ -2311,25 +2264,6 @@ export function ContentPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">最新新增</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingSection.isNew ?? false}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
isNew: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">标记 NEW</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">小程序直推</Label>
|
||||
<div className="flex items-center h-10">
|
||||
@@ -2349,43 +2283,6 @@ export function ContentPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">文章类型</Label>
|
||||
<div className="flex items-center gap-4 h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="edition-type"
|
||||
checked={editingSection.editionPremium !== true}
|
||||
onChange={() =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
editionStandard: true,
|
||||
editionPremium: false,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">普通版</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="edition-type"
|
||||
checked={editingSection.editionPremium === true}
|
||||
onChange={() =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
editionStandard: false,
|
||||
editionPremium: true,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">增值版</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">热度分</Label>
|
||||
<Input
|
||||
@@ -2493,10 +2390,7 @@ export function ContentPage() {
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
内容搜索
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="link-person" className="data-[state=active]:bg-purple-500/20 data-[state=active]:text-purple-400 text-gray-400">
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
@列表
|
||||
</TabsTrigger>
|
||||
{/* @列表已整合到 用户管理 → 超级个体;保留 persons state 供编辑器 @ 提示 */}
|
||||
<TabsTrigger value="link-tag" className="data-[state=active]:bg-amber-500/20 data-[state=active]:text-amber-400 text-gray-400">
|
||||
<Hash className="w-4 h-4 mr-2" />
|
||||
链接标签
|
||||
@@ -2673,6 +2567,29 @@ export function ContentPage() {
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">小程序未付费用户默认显示文章前 {previewPercent}% 内容;章节「预览%」可单独覆盖</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-gray-400 text-sm whitespace-nowrap">全书解锁阈值</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
className="bg-[#0a1628] border-gray-700 text-white w-20"
|
||||
value={fullbookThreshold}
|
||||
onChange={(e) => setFullbookThreshold(Math.max(1, Number(e.target.value) || 2))}
|
||||
/>
|
||||
<span className="text-gray-500 text-sm">小节</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveFullbookThreshold}
|
||||
disabled={fullbookThresholdSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{fullbookThresholdSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">用户已购买 ≥ {fullbookThreshold} 小节后,阅读页付费墙显示「解锁全书」按钮</span>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="text-gray-400 text-sm">阅读页 / 朋友圈文案(JSON)</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
@@ -2825,338 +2742,9 @@ export function ContentPage() {
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="link-person" className="space-y-4">
|
||||
{/* AI列表(@人物) */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<span className="text-[#38bdac] text-lg font-bold">@</span>
|
||||
AI列表 — @列表(编辑器内输入 @ 可链接)
|
||||
</CardTitle>
|
||||
<div className="text-xs text-gray-500 mt-1 space-y-1">
|
||||
<p>
|
||||
文章 @ 存 <span className="text-gray-400">token</span>;小程序点 @ 用 token 换存客宝密钥后加好友/拉群。
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-purple-300">超级个体</span>:与「用户管理 → 超级个体列表」同口径的有效 VIP;每次打开/刷新本页时,后端会为尚未出现在本表的用户自动补一条可 @ 人物(昵称须合法、存客宝可用)。已配置统一计划时走「超级个体同步」获客计划。
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[#38bdac]">飞书群 Webhook</span>(按人推线索到群):在下方表格「飞书群」列查看状态,操作栏点 Webhook 图标配置;与绑定会员用户维度一致。
|
||||
</p>
|
||||
<p>点「添加」新建的人物:每人单独一条存客宝计划(SOUL链接人与事-名称)。</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500">本页只管理可 @ 的人物列表</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-600 text-gray-400 hover:bg-gray-700/50"
|
||||
onClick={() => {
|
||||
loadPersons()
|
||||
}}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
onClick={() => {
|
||||
setEditingPerson(null)
|
||||
setPersonModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{persons.length > 0 ? (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-700/50">
|
||||
<th className="text-left py-1.5 px-3 w-[280px] font-normal">token</th>
|
||||
<th className="text-left py-1.5 px-3 w-24 font-normal">@的人</th>
|
||||
<th className="text-left py-1.5 px-3 w-[72px] font-normal text-xs">来源</th>
|
||||
<th className="text-left py-1.5 px-3 w-[100px] font-normal text-xs">会员</th>
|
||||
<th className="py-1.5 px-3 w-16 font-normal text-center">获客数</th>
|
||||
<th className="text-left py-1.5 px-3 font-normal">获客计划</th>
|
||||
<th className="text-left py-1.5 px-2 w-[72px] font-normal text-xs">飞书群</th>
|
||||
<th className="text-center py-1.5 px-2 w-14 font-normal text-xs">置顶</th>
|
||||
<th className="text-left py-1.5 px-3 w-16 font-normal">状态</th>
|
||||
<th className="text-left py-1.5 px-2 w-24 font-normal">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{persons.map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-700/30 hover:bg-[#0a1628]/80">
|
||||
<td className="py-2 px-3 text-gray-400 text-xs font-mono" title="32位token">{p.id}</td>
|
||||
<td className="py-2 px-3 truncate max-w-[96px]">
|
||||
<button
|
||||
type="button"
|
||||
className="text-amber-400 hover:text-amber-300 hover:underline text-left"
|
||||
onClick={() => { if (ckbLeadCounts[p.id]) openCkbLeadDetail(p.id, p.name) }}
|
||||
title={ckbLeadCounts[p.id] ? '点击查看获客详情' : p.name}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{p.personSource === 'vip_sync' ? (
|
||||
<span className="text-[10px] text-purple-300 bg-purple-500/15 px-1.5 py-0.5 rounded whitespace-nowrap">超级个体</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-500 bg-gray-500/10 px-1.5 py-0.5 rounded whitespace-nowrap">手工</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs">
|
||||
{p.userId ? (
|
||||
<div className="flex flex-col gap-0.5 items-start max-w-[100px]">
|
||||
{p.personSource === 'vip_sync' && (
|
||||
<span className="text-[10px] text-purple-300/90 leading-tight">已绑定超级个体</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#38bdac] hover:underline truncate max-w-[96px] block text-left"
|
||||
title={`用户ID: ${p.userId}`}
|
||||
onClick={() => navigate(`/users?search=${encodeURIComponent(p.userId || '')}`)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
{(() => {
|
||||
const leadCount = ckbLeadCounts[p.id] || 0
|
||||
return (
|
||||
<td
|
||||
className={`py-2 px-3 shrink-0 w-16 text-center text-xs font-bold cursor-pointer ${leadCount > 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}
|
||||
</td>
|
||||
)
|
||||
})()}
|
||||
<td className="py-2 px-3 text-white truncate max-w-[220px]" title={`planId: ${p.ckbPlanId ?? '-'}`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate">
|
||||
{p.ckbPlanId
|
||||
? (p.personSource === 'vip_sync'
|
||||
? '超级个体拉群(统一计划)'
|
||||
: `SOUL链接人与事-${p.name}`)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-xs">
|
||||
{p.userId ? (
|
||||
vipWebhookByUserId[(p.userId || '').trim()] ? (
|
||||
<span className="text-[#38bdac]" title={vipWebhookByUserId[(p.userId || '').trim()]}>已配置</span>
|
||||
) : (
|
||||
<span className="text-gray-500">未配置</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={
|
||||
p.isPinned
|
||||
? 'text-amber-400 hover:text-amber-300 h-7 px-2'
|
||||
: 'text-gray-500 hover:text-amber-400/90 h-7 px-2'
|
||||
}
|
||||
title={p.isPinned ? '取消小程序首页置顶' : '设为小程序首页置顶(全局唯一)'}
|
||||
onClick={() => void handlePersonPin(p, !p.isPinned)}
|
||||
>
|
||||
<Pin className={`w-3.5 h-3.5 ${p.isPinned ? 'fill-amber-400' : ''}`} />
|
||||
</Button>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{p.ckbPlanId ? (
|
||||
<span className="text-[10px] text-green-400 bg-green-400/10 px-1.5 py-0.5 rounded">启用</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-500 bg-gray-500/10 px-1.5 py-0.5 rounded">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 ${p.userId ? 'text-[#38bdac] hover:text-[#5fe0cd]' : 'text-gray-600 cursor-not-allowed opacity-50'}`}
|
||||
title={p.userId ? '设置飞书群 Webhook(点击头像留资推送)' : '未绑定会员,无法配置'}
|
||||
disabled={!p.userId}
|
||||
onClick={() => openPersonVipWebhook(p)}
|
||||
>
|
||||
<Webhook className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-400 hover:text-[#38bdac] h-6 px-2"
|
||||
title="编辑"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await getPersonDetail(p.personId || '')
|
||||
if (res?.success && res.person) {
|
||||
const d = res.person
|
||||
setEditingPerson({
|
||||
id: d.token ?? d.personId,
|
||||
personId: d.personId,
|
||||
name: d.name,
|
||||
personSource: (d as { personSource?: string }).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,
|
||||
} as PersonItem)
|
||||
} else {
|
||||
setEditingPerson(p)
|
||||
if (res?.error) toast.error(res.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setEditingPerson(p)
|
||||
toast.error(e instanceof Error ? e.message : '加载人物详情失败')
|
||||
}
|
||||
setPersonModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-400 hover:text-green-400 h-6 px-2"
|
||||
title="查看新客户"
|
||||
onClick={() => openCkbLeadDetail(p.id, p.name)}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 h-6 px-2"
|
||||
title={p.personSource === 'vip_sync' ? '删除本地 @人物(不删统一获客计划)' : '删除(同时删除存客宝对应获客计划)'}
|
||||
onClick={() => setPersonToDelete(p)}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm py-4 text-center">暂无AI人物,添加后可在编辑器中 @链接</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* @列表已整合到用户管理→超级个体,保留 loadPersons/PersonAddEditModal 供编辑器用 */}
|
||||
|
||||
{/* 存客宝绑定配置(与 @列表 同一 tab) */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4 text-[#38bdac]" />
|
||||
存客宝绑定
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
配置存客宝 API 后,文章中 @人物 或 #标签 点击可自动进入存客宝流量池
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-400 text-xs">存客宝 API 地址</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8"
|
||||
placeholder="https://ckbapi.quwanzhi.com"
|
||||
defaultValue="https://ckbapi.quwanzhi.com"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-400 text-xs">绑定计划</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8"
|
||||
placeholder="创业实验-内容引流"
|
||||
defaultValue="创业实验-内容引流"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
具体存客宝场景配置与接口测试请前往{' '}
|
||||
<button
|
||||
className="text-[#38bdac] hover:underline"
|
||||
onClick={() => window.open('/match', '_blank')}
|
||||
>
|
||||
找伙伴 → 存客宝工作台
|
||||
</button>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<Settings2 className="w-4 h-4 text-blue-400" />
|
||||
获客 Webhook 通知
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">配置后新获客线索将自动推送到群聊(支持企业微信/飞书 Webhook)</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-gray-400 text-xs">Webhook URL</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||
value={ckbWebhookUrl}
|
||||
onChange={(e) => setCkbWebhookUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white h-8"
|
||||
onClick={async () => {
|
||||
const url = ckbWebhookUrl.trim()
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||||
key: 'ckb_lead_webhook_url',
|
||||
value: url,
|
||||
description: '获客线索 Webhook 通知 URL(企微/飞书)',
|
||||
})
|
||||
if (res?.success) toast.success(url ? 'Webhook 已保存' : 'Webhook 已清除')
|
||||
else toast.error(res?.error ?? '保存失败')
|
||||
} catch { toast.error('保存失败') }
|
||||
}}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">配置企业微信或飞书群机器人 Webhook URL,获客成功后自动推送通知</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/* @列表已整合到用户管理→超级个体;PersonAddEditModal 仍保留供编辑器 @ 使用 */}
|
||||
|
||||
<TabsContent value="link-tag" className="space-y-4">
|
||||
{/* #链接标签管理 */}
|
||||
|
||||
@@ -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<VipMemberLite[]>([])
|
||||
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
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<string, number> }>('/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() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6 mb-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
|
||||
<div className="grid gap-6 mb-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -685,6 +672,17 @@ export function DashboardPage() {
|
||||
>
|
||||
超级个体统计
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setBottomTab('journey'); loadJourneyStats() }}
|
||||
className={`px-5 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
bottomTab === 'journey'
|
||||
? 'bg-[#38bdac] text-white'
|
||||
: 'bg-[#0f2137] text-gray-400 hover:text-white hover:bg-gray-700/50 border border-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
用户旅程
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bottomTab === 'overview' && (
|
||||
@@ -1109,6 +1107,80 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{bottomTab === 'journey' && (
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white">用户旅程总览</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadJourneyStats}
|
||||
disabled={journeyLoading}
|
||||
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${journeyLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400 text-sm mb-6">用户从注册到 VIP 的完整行动路径</p>
|
||||
{journeyLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute top-16 left-0 right-0 h-0.5 bg-gradient-to-r from-blue-500/20 via-[#38bdac]/30 to-amber-500/20 mx-20" />
|
||||
<div className="grid grid-cols-4 gap-4 lg:grid-cols-8">
|
||||
{JOURNEY_STAGES.map((stage, idx) => {
|
||||
const count = journeyStats[stage.id] || 0
|
||||
return (
|
||||
<div key={stage.id} className="relative flex flex-col items-center">
|
||||
<div className={`relative w-full p-3 rounded-xl border ${stage.color} text-center`}>
|
||||
<div className="text-2xl mb-1">{stage.icon}</div>
|
||||
<div className={`text-xs font-medium ${stage.color.split(' ').find(c => c.startsWith('text-'))}`}>{stage.label}</div>
|
||||
<div className="mt-1.5 text-xs text-gray-400">
|
||||
<span className="font-bold text-white">{count}</span> 人
|
||||
</div>
|
||||
<div className="absolute -top-2.5 -left-2.5 w-5 h-5 rounded-full bg-[#0a1628] border border-gray-700 flex items-center justify-center text-[10px] text-gray-500">{idx + 1}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(journeyStats).length > 0 && (
|
||||
<div className="bg-[#0a1628] border border-gray-700/40 rounded-lg p-4">
|
||||
<p className="text-white font-medium mb-3">各阶段占比</p>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
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 (
|
||||
<div key={stage.id} className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs w-[5.5rem] shrink-0">{stage.icon} {stage.label}</span>
|
||||
<div className="flex-1 h-2.5 bg-[#0f2137] rounded-full overflow-hidden border border-gray-700/40">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#38bdac]/50 to-[#38bdac] transition-all" style={{ width: `${barW}%` }} />
|
||||
</div>
|
||||
<span className="text-gray-400 text-xs w-14 text-right tabular-nums">{count}</span>
|
||||
<span className="text-gray-600 text-[10px] w-8 text-right tabular-nums">{totalAll > 0 ? `${pct}%` : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<UserDetailModal
|
||||
open={showDetailModal}
|
||||
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
|
||||
|
||||
@@ -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<CkbLeadsPanelHandle>(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<Order[]>([])
|
||||
const [overview, setOverview] = useState<DistributionOverview | null>(null)
|
||||
const [bindings, setBindings] = useState<Binding[]>([])
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
@@ -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() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">推广中心</h1>
|
||||
<p className="text-gray-500 text-sm mt-0.5">分销绑定、提现审核、推广设置</p>
|
||||
<p className="text-gray-500 text-sm mt-0.5">
|
||||
获客、绑定、收益、提现 · 存客宝配置 · 推广漏斗见「数据概览」
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={refreshCurrentTab}
|
||||
@@ -596,30 +570,37 @@ export function DistributionPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-6 bg-[#0a1628] rounded-lg p-1 border border-gray-700/40">
|
||||
<div className="bg-[#0a1628] border border-gray-700/50 p-1 mb-6 flex flex-wrap gap-1 rounded-lg">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.key as typeof activeTab)
|
||||
setActiveTab(tab.key)
|
||||
if (tab.key === 'leads') setLoading(false)
|
||||
setSearchParams((prev) => {
|
||||
const p = new URLSearchParams(prev)
|
||||
p.set('tab', tab.key)
|
||||
return p
|
||||
})
|
||||
setStatusFilter('all')
|
||||
setSearchTerm('')
|
||||
if (tab.key !== 'orders') setOrderSubView('orders')
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm transition-all ${
|
||||
className={`text-xs font-medium px-2.5 py-1.5 rounded-md transition-colors inline-flex items-center gap-1.5 ${
|
||||
activeTab === tab.key
|
||||
? 'bg-[#38bdac] text-white shadow-md'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/40'
|
||||
? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/40 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-3.5 h-3.5" />
|
||||
<tab.icon className="w-3.5 h-3.5 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -632,138 +613,8 @@ export function DistributionPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && overview && (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" />
|
||||
推广转化漏斗
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => void refreshCurrentTab()} disabled={loading} className="text-gray-400 h-7">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-500 text-xs border-b border-gray-700/50">
|
||||
<th className="pb-2 text-left font-normal">指标</th>
|
||||
<th className="pb-2 text-right font-normal">今日</th>
|
||||
<th className="pb-2 text-right font-normal">本月</th>
|
||||
<th className="pb-2 text-right font-normal">累计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white">
|
||||
<tr className="border-b border-gray-700/30">
|
||||
<td className="py-2.5 flex items-center gap-2"><Eye className="w-4 h-4 text-blue-400" />点击数</td>
|
||||
<td className="py-2.5 text-right font-bold">{overview.todayClicks}</td>
|
||||
<td className="py-2.5 text-right">{overview.monthClicks}</td>
|
||||
<td className="py-2.5 text-right">{overview.totalClicks}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-700/30">
|
||||
<td className="py-2.5 flex items-center gap-2"><Link2 className="w-4 h-4 text-green-400" />绑定关系</td>
|
||||
<td className="py-2.5 text-right font-bold">{overview.todayBindings}</td>
|
||||
<td className="py-2.5 text-right">{overview.monthBindings}</td>
|
||||
<td className="py-2.5 text-right">{overview.totalBindings}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-700/30">
|
||||
<td className="py-2.5 flex items-center gap-2"><CheckCircle className="w-4 h-4 text-purple-400" />付款转化</td>
|
||||
<td className="py-2.5 text-right font-bold">{overview.todayConversions}</td>
|
||||
<td className="py-2.5 text-right">{overview.monthConversions}</td>
|
||||
<td className="py-2.5 text-right">{overview.totalConversions}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2.5 flex items-center gap-2"><DollarSign className="w-4 h-4 text-[#38bdac]" />佣金收入</td>
|
||||
<td className="py-2.5 text-right font-bold text-[#38bdac]">¥{(overview.todayEarnings ?? 0).toFixed(0)}</td>
|
||||
<td className="py-2.5 text-right text-[#38bdac]">¥{(overview.monthEarnings ?? 0).toFixed(0)}</td>
|
||||
<td className="py-2.5 text-right text-[#38bdac]">¥{(overview.totalEarnings ?? 0).toFixed(0)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{overview.conversionRate && (
|
||||
<p className="text-xs text-gray-500 mt-3 text-right">综合转化率 {overview.conversionRate}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-orange-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-orange-300 font-medium text-sm">即将过期绑定</p>
|
||||
<p className="text-xl font-bold text-white">{overview.expiringBindings} <span className="text-sm font-normal text-orange-300/60">个 · 7天内</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-blue-500/10 border-blue-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-blue-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-blue-300 font-medium text-sm">待审核提现</p>
|
||||
<p className="text-xl font-bold text-white">{overview.pendingWithdrawals} <span className="text-sm font-normal text-blue-300/60">笔 · ¥{(overview.pendingWithdrawAmount ?? 0).toFixed(0)}</span></p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setActiveTab('withdrawals')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20 shrink-0"
|
||||
>
|
||||
去审核
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-emerald-500/10 border-emerald-500/30">
|
||||
<CardContent className="p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-emerald-300 font-medium text-sm">获客线索(存客宝)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
留资列表、推送状态与重试已统一至「用户管理 → 获客列表」,避免与推广中心重复维护。
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/users?tab=leads" className="shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-500/50 text-emerald-400 hover:bg-emerald-500/15 bg-transparent"
|
||||
>
|
||||
打开获客列表
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-white/5">
|
||||
<Users className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{overview.totalDistributors}</p>
|
||||
<p className="text-[10px] text-gray-500">推广用户</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-white/5">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-lg font-bold text-green-400">{overview.activeDistributors}</p>
|
||||
<p className="text-[10px] text-gray-500">有收益用户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{activeTab === 'leads' && (
|
||||
<CkbLeadsPanel ref={leadsPanelRef} urlSyncLeadAction active />
|
||||
)}
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
@@ -1455,6 +1306,9 @@ export function DistributionPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 存客宝配置 Tab */}
|
||||
{activeTab === 'ckb' && <CKBConfigPanel initialTab="overview" />}
|
||||
|
||||
{/* 推广设置 Tab */}
|
||||
{activeTab === 'settings' && (
|
||||
<div className="-mx-8 -mt-6">
|
||||
|
||||
@@ -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<TabId>('partner')
|
||||
const [showCKBPanel, setShowCKBPanel] = useState(false)
|
||||
const [tabConfigs, setTabConfigs] = useState<TabConfig[]>([])
|
||||
const [showConfigModal, setShowConfigModal] = useState(false)
|
||||
const [editConfigs, setEditConfigs] = useState<TabConfig[]>([])
|
||||
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 (
|
||||
<div className="p-8 w-full max-w-7xl mx-auto">
|
||||
@@ -29,39 +94,37 @@ export function FindPartnerPage() {
|
||||
找伙伴
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm mt-0.5">
|
||||
匹配、获客、导师与团队管理 · 汇总数据见「仪表盘」与「推广中心」
|
||||
匹配、导师与团队管理 · 存客宝配置已移至「推广中心」
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCKBPanel((v) => !v)}
|
||||
className={`border-orange-500/30 text-orange-300 hover:bg-orange-500/10 bg-transparent text-xs ${showCKBPanel ? 'bg-orange-500/10' : ''}`}
|
||||
onClick={openConfig}
|
||||
className="border-gray-600 text-gray-400 hover:bg-gray-700/50 bg-transparent text-xs"
|
||||
title="配置 Tab 显示名与显隐"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
存客宝
|
||||
<ChevronRight className={`w-3 h-3 ml-1 transition-transform ${showCKBPanel ? 'rotate-90' : ''}`} />
|
||||
<Settings className="w-3.5 h-3.5 mr-1" />
|
||||
Tab 配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCKBPanel && <CKBConfigPanel initialTab="overview" />}
|
||||
|
||||
<div className="flex gap-1 mb-6 bg-[#0a1628] rounded-lg p-1 border border-gray-700/40">
|
||||
{TABS.map((tab) => {
|
||||
<div className="bg-[#0a1628] border border-gray-700/50 p-1 mb-6 flex flex-wrap gap-1 rounded-lg">
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm transition-all ${
|
||||
className={`text-xs font-medium px-2.5 py-1.5 rounded-md transition-colors inline-flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? 'bg-[#38bdac] text-white shadow-md'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/40'
|
||||
? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/40 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-3.5 h-3.5" />
|
||||
<tab.icon className="w-3.5 h-3.5 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
@@ -72,6 +135,53 @@ export function FindPartnerPage() {
|
||||
{activeTab === 'resource' && <ResourceDockingTab />}
|
||||
{activeTab === 'mentor' && <MentorTab />}
|
||||
{activeTab === 'team' && <TeamRecruitTab />}
|
||||
|
||||
{/* Tab 配置弹窗 */}
|
||||
{showConfigModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowConfigModal(false)}>
|
||||
<div className="bg-[#0f2137] border border-gray-700 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-white font-semibold mb-4">找伙伴 Tab 配置</h3>
|
||||
<p className="text-gray-500 text-xs mb-4">修改后保存即刻生效(管理端 + 小程序均读此配置)</p>
|
||||
<div className="space-y-3">
|
||||
{editConfigs.map((cfg, idx) => {
|
||||
const dt = DEFAULT_TABS.find((t) => t.id === cfg.id)
|
||||
return (
|
||||
<div key={cfg.id} className="flex items-center gap-3 p-3 bg-[#0a1628] rounded-lg border border-gray-700/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!cfg.hidden}
|
||||
onChange={(e) => {
|
||||
const next = [...editConfigs]
|
||||
next[idx] = { ...cfg, hidden: !e.target.checked }
|
||||
setEditConfigs(next)
|
||||
}}
|
||||
className="accent-[#38bdac]"
|
||||
/>
|
||||
<input
|
||||
className="flex-1 bg-transparent border border-gray-600 rounded px-2 py-1 text-sm text-white"
|
||||
value={cfg.label || ''}
|
||||
onChange={(e) => {
|
||||
const next = [...editConfigs]
|
||||
next[idx] = { ...cfg, label: e.target.value }
|
||||
setEditConfigs(next)
|
||||
}}
|
||||
placeholder={dt?.label}
|
||||
/>
|
||||
<span className="text-gray-600 text-xs w-16 text-right">{dt?.id}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowConfigModal(false)} className="border-gray-600 text-gray-400">取消</Button>
|
||||
<Button size="sm" onClick={saveConfig} disabled={saving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
{saving && <RefreshCw className="w-3.5 h-3.5 mr-1.5 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 匹配算法配置 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-purple-400" /> 匹配算法
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
按优先级从高到低执行:先尝试标签匹配,逐层降级,最终兜底随机匹配。权重影响综合评分。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS).map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`flex items-center gap-4 p-3 rounded-lg border transition-all ${
|
||||
layer.enabled
|
||||
? 'bg-[#0a1628] border-gray-700/50'
|
||||
: 'bg-[#0a1628]/50 border-gray-700/25 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-500 text-xs font-mono w-5 text-right shrink-0">#{idx + 1}</span>
|
||||
<Switch
|
||||
checked={layer.enabled}
|
||||
onCheckedChange={(v) => {
|
||||
const layers = [...(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS)]
|
||||
layers[idx] = { ...layers[idx], enabled: v }
|
||||
setConfig({ ...config, algorithmLayers: layers })
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-sm font-medium">{layer.label}</span>
|
||||
<Badge className="bg-purple-500/15 text-purple-300 border-0 text-[10px]">
|
||||
权重 {layer.weight}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-0.5">{layer.desc}</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="w-20 bg-[#0f2137] border-gray-700 text-white text-center text-xs h-8"
|
||||
value={layer.weight}
|
||||
onChange={(e) => {
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-600 text-[10px] mt-3">
|
||||
总权重:{(config.algorithmLayers ?? DEFAULT_ALGORITHM_LAYERS).filter(l => l.enabled).reduce((s, l) => s + l.weight, 0)}%
|
||||
{' · '}修改后点击上方「保存配置」生效
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2"><Zap className="w-5 h-5 text-yellow-400" /> 基础设置</CardTitle>
|
||||
|
||||
@@ -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<string, string> = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' }
|
||||
const typeMap: Record<string, string> = { single: '单次', half_year: '半年', year: '年度' }
|
||||
|
||||
export function MentorBookingTab() {
|
||||
export function MentorBookingTab({ onSwitchToManage }: { onSwitchToManage?: () => void }) {
|
||||
const [list, setList] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [mentorMap, setMentorMap] = useState<Record<number, string>>({})
|
||||
|
||||
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<number, string> = {}
|
||||
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() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">ID</TableHead>
|
||||
<TableHead className="text-gray-400">用户ID</TableHead>
|
||||
<TableHead className="text-gray-400">导师ID</TableHead>
|
||||
<TableHead className="text-gray-400">用户</TableHead>
|
||||
<TableHead className="text-gray-400">导师</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">金额</TableHead>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
@@ -64,17 +80,29 @@ export function MentorBookingTab() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map(r => (
|
||||
<TableRow key={r.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{r.id}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.userId}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
|
||||
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
|
||||
<TableCell className="text-white">¥{r.amount}</TableCell>
|
||||
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{list.map(r => {
|
||||
const mName = r.mentorName || mentorMap[r.mentorId] || `#${r.mentorId}`
|
||||
return (
|
||||
<TableRow key={r.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{r.id}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.userName || r.userId}</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#38bdac] hover:text-[#2da396] hover:underline text-sm text-left"
|
||||
onClick={() => onSwitchToManage?.()}
|
||||
title={`导师 ID: ${r.mentorId},点击跳转导师管理`}
|
||||
>
|
||||
{mName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
|
||||
<TableCell className="text-white">¥{r.amount}</TableCell>
|
||||
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center py-12 text-gray-500">暂无预约记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function MentorTab() {
|
||||
导师管理
|
||||
</button>
|
||||
</div>
|
||||
{subTab === 'booking' && <MentorBookingTab />}
|
||||
{subTab === 'booking' && <MentorBookingTab onSwitchToManage={() => setSubTab('manage')} />}
|
||||
{subTab === 'manage' && (
|
||||
<div className="-mx-8">
|
||||
<MentorsPage embedded />
|
||||
|
||||
@@ -703,6 +703,13 @@ export function SettingsPage() {
|
||||
<p className="text-xs text-gray-500 pt-2 border-t border-gray-700/50">
|
||||
弹窗类文案在「弹窗文案」子 Tab 按页面路径 + 英文键维护(pagePopupItems);目录、Tab、首页板块等仍由其它配置决定。
|
||||
</p>
|
||||
<p className="text-xs text-[#38bdac]/90 pt-2">
|
||||
首页右上角「打赏 / 上麦」配置已按超级个体独立管理,请到{' '}
|
||||
<Link to="/users?tab=vip-roles" className="underline hover:text-[#38bdac]">
|
||||
用户管理 → 超级个体
|
||||
</Link>
|
||||
,展开对应成员行即可配置。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
576
soul-admin/src/pages/users/HomeEntryConfigTab.tsx
Normal file
@@ -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<string, unknown> {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
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<Record<string, unknown> | 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<GiftRow[]>([...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<string, unknown>) => {
|
||||
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<string, unknown>
|
||||
}>('/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<GiftRow>) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||
<RefreshCw className="w-6 h-6 mr-2 animate-spin text-[#38bdac]" />
|
||||
加载首页入口配置…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-gray-400">
|
||||
配置小程序<strong className="text-gray-300">首页右上角</strong>
|
||||
入口:后台保存后,用户<strong className="text-gray-300">每次进入首页会拉最新配置</strong>
|
||||
(无需发版)。非直播时段走「链接 / 打赏」或置顶;直播时段入口可切为「申请上麦」,点击后打开
|
||||
<strong className="text-gray-300">与上方相同的礼物支付弹窗</strong>,支付成功推送见下方 Webhook。
|
||||
</p>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Gift className="w-5 h-5 text-[#38bdac]" />
|
||||
打赏弹窗(链接 / 支持)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
非直播时段或未开启「定时上麦」时,与置顶 @ 人物逻辑并存:有置顶时优先展示置顶链接文案;仅开启打赏且无置顶时展示入口按钮文案。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-[#0a1628] border-gray-700/50">
|
||||
<div>
|
||||
<Label className="text-white">开启打赏弹窗</Label>
|
||||
<p className="text-xs text-gray-500 mt-1">关闭后仅保留置顶人物「点击链接」行为(若有置顶)</p>
|
||||
</div>
|
||||
<Switch checked={rewardEnabled} onCheckedChange={setRewardEnabled} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">入口按钮文案</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={entryButtonText}
|
||||
onChange={(e) => setEntryButtonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">弹窗标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={modalTitle}
|
||||
onChange={(e) => setModalTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label className="text-gray-300">弹窗副标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={modalSubtitle}
|
||||
onChange={(e) => setModalSubtitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">支付按钮文案</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={payButtonText}
|
||||
onChange={(e) => setPayButtonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">留资链接文案</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={leadLinkText}
|
||||
onChange={(e) => setLeadLinkText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label className="text-gray-300">支付成功提示</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={afterPayToast}
|
||||
onChange={(e) => setAfterPayToast(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">礼物档位</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addGift} className="border-gray-600 text-gray-300">
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
<strong className="text-gray-400">上麦横排</strong>:排序数字越小越靠左;图标缩放×相对基础大小;仅一条可设为「默认支付档」(高亮 + 与下方「默认档放大倍数」相乘)。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{gifts.map((g, i) => (
|
||||
<div
|
||||
key={`${g.id}-${i}`}
|
||||
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg bg-[#0a1628] border border-gray-700/50"
|
||||
>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">id</span>
|
||||
<Input
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.id}
|
||||
onChange={(e) => updateGift(i, { id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">名称</span>
|
||||
<Input
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.name}
|
||||
onChange={(e) => updateGift(i, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">价格</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.price}
|
||||
onChange={(e) => updateGift(i, { price: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">emoji</span>
|
||||
<Input
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.emoji}
|
||||
onChange={(e) => updateGift(i, { emoji: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end pb-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 h-8 w-8 p-0"
|
||||
onClick={() => removeGift(i)}
|
||||
disabled={gifts.length <= 1}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-span-12 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">图标 URL(可选)</span>
|
||||
<Input
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.icon}
|
||||
onChange={(e) => updateGift(i, { icon: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">上麦排序</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.micOrder}
|
||||
onChange={(e) => updateGift(i, { micOrder: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 space-y-1">
|
||||
<span className="text-[10px] text-gray-500">图标缩放×</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0.5}
|
||||
max={2}
|
||||
className="bg-[#050f1c] border-gray-700 text-white text-xs h-8"
|
||||
value={g.iconScale}
|
||||
onChange={(e) =>
|
||||
updateGift(i, { iconScale: parseFloat(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-end pb-1 gap-2">
|
||||
<Switch
|
||||
checked={g.micFeatured}
|
||||
onCheckedChange={(v) => {
|
||||
setGifts((rows) =>
|
||||
rows.map((r, j) =>
|
||||
j === i ? { ...r, micFeatured: v } : v ? { ...r, micFeatured: false } : r,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400 whitespace-nowrap">默认支付档</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Mic className="w-5 h-5 text-amber-400" />
|
||||
直播时段 · 申请上麦
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
在时段内右上角显示配置的入口文案与图标;用户点击后弹出<strong>礼物选择 + 微信支付</strong>(礼物列表与上图「打赏弹窗」共用)。支付成功后订单推送:若填写了「上麦支付
|
||||
Webhook」则推到该地址,否则与<strong>系统设置里订单支付默认 Webhook</strong>(order_paid_webhook_url /
|
||||
获客 ckb_lead_webhook_url)一致。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-[#0a1628] border-gray-700/50">
|
||||
<div>
|
||||
<Label className="text-white">启用定时上麦入口</Label>
|
||||
<p className="text-xs text-gray-500 mt-1">例:18:00–21:00 显示上麦入口,点击走礼物支付;其余时间显示打赏或置顶</p>
|
||||
</div>
|
||||
<Switch checked={micEnabled} onCheckedChange={setMicEnabled} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">开始时间</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="18:00"
|
||||
value={micStart}
|
||||
onChange={(e) => setMicStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">结束时间</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="21:00"
|
||||
value={micEnd}
|
||||
onChange={(e) => setMicEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">入口图标(emoji)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={micEmoji}
|
||||
onChange={(e) => setMicEmoji(e.target.value)}
|
||||
placeholder="🎤"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2 md:col-span-1">
|
||||
<Label className="text-gray-300">按钮文案</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={micButtonText}
|
||||
onChange={(e) => setMicButtonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">弹窗标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={micModalTitle}
|
||||
onChange={(e) => setMicModalTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">弹窗说明</Label>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[80px]"
|
||||
value={micModalContent}
|
||||
onChange={(e) => setMicModalContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<Label className="text-gray-300">默认档图标放大倍数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={1}
|
||||
max={2.4}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={micCenterEmojiScale}
|
||||
onChange={(e) => setMicCenterEmojiScale(parseFloat(e.target.value) || 1.55)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">仅作用于「默认支付档」相对两侧的 emoji/图标大小,与礼物行的「图标缩放」相乘。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">支付成功 Toast(上麦入口礼物支付后)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={micAfterSubmitToast}
|
||||
onChange={(e) => setMicAfterSubmitToast(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">上麦支付 Webhook(可选)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/... 或企微机器人"
|
||||
value={paidWebhookUrl}
|
||||
onChange={(e) => setPaidWebhookUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
仅当用户从<strong>直播时段上麦入口</strong>完成礼物支付时推送到此地址;留空则走订单默认群 Webhook(与「打赏」同一套兜底)。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300"
|
||||
disabled={saving}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{saving ? '保存中…' : '保存到小程序配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
727
soul-admin/src/pages/users/SuperIndividualTab.tsx
Normal file
@@ -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<VipMember[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||
const [roleTarget, setRoleTarget] = useState<VipMember | null>(null)
|
||||
const [roleInput, setRoleInput] = useState('')
|
||||
const [roleSaving, setRoleSaving] = useState(false)
|
||||
|
||||
const [showSortModal, setShowSortModal] = useState(false)
|
||||
const [sortTarget, setSortTarget] = useState<VipMember | null>(null)
|
||||
const [sortInput, setSortInput] = useState('')
|
||||
const [sortSaving, setSortSaving] = useState(false)
|
||||
|
||||
const [showWebhookModal, setShowWebhookModal] = useState(false)
|
||||
const [webhookTarget, setWebhookTarget] = useState<VipMember | null>(null)
|
||||
const [webhookInput, setWebhookInput] = useState('')
|
||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false)
|
||||
const [editingPersonData, setEditingPersonData] = useState<any>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<VipMember | null>(null)
|
||||
|
||||
const [userDetailId, setUserDetailId] = useState<string | null>(null)
|
||||
|
||||
const [leadDetailOpen, setLeadDetailOpen] = useState(false)
|
||||
const [leadDetailName, setLeadDetailName] = useState('')
|
||||
const [leadDetailRecords, setLeadDetailRecords] = useState<any[]>([])
|
||||
const [leadDetailLoading, setLeadDetailLoading] = useState(false)
|
||||
|
||||
/** personId -> 最近一次存客宝计划探测结果(与 /api/admin/ckb/plan-check 对齐) */
|
||||
const [planHealthByPerson, setPlanHealthByPerson] = useState<Record<string, CkbPlanHealth>>({})
|
||||
const [planCheckLoading, setPlanCheckLoading] = useState(false)
|
||||
const [planCheckRowPid, setPlanCheckRowPid] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 text-sm">以超级个体为核心:排序、标签、置顶、打赏/上麦、Webhook、@人物管理。拖拽排序。</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => { setEditingPersonData(null); setPersonModalOpen(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加@人物
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void checkAllCkbPlans()}
|
||||
disabled={planCheckLoading || loading}
|
||||
className="border-emerald-600/50 text-emerald-400 hover:bg-emerald-500/10 bg-transparent"
|
||||
title="调用存客宝开放接口,对全部已配置计划的 @人物 做一次连通性检测(会尝试启用计划)"
|
||||
>
|
||||
<Stethoscope className={`w-4 h-4 mr-1 ${planCheckLoading ? 'animate-pulse' : ''}`} />
|
||||
存客宝检测
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">加载中...</span></div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50"><Crown className="w-12 h-12 text-amber-400/30 mx-auto mb-4" /><p className="text-gray-400">当前没有有效的超级个体。</p></div>
|
||||
) : (
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400 w-7"></TableHead>
|
||||
<TableHead className="text-gray-400 w-7">#</TableHead>
|
||||
<TableHead className="text-gray-400">成员</TableHead>
|
||||
<TableHead className="text-gray-400 w-10 text-center">📌</TableHead>
|
||||
<TableHead className="text-gray-400 w-12 text-center">获客</TableHead>
|
||||
<TableHead className="text-gray-400 w-12 text-center">点击</TableHead>
|
||||
<TableHead className="text-gray-400 w-16 text-center">计划</TableHead>
|
||||
<TableHead className="text-gray-400 w-20 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((m, idx) => {
|
||||
const ex = expandedId === m.id
|
||||
const hasPlan = !!m.ckbPlanId
|
||||
return (
|
||||
<React.Fragment key={m.id}>
|
||||
<TableRow
|
||||
draggable
|
||||
onDragStart={e => 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' : ''}`}
|
||||
>
|
||||
<TableCell className="px-1">
|
||||
<button type="button" className="text-gray-500 hover:text-gray-300 p-0.5" onClick={() => setExpandedId(ex ? null : m.id)}>
|
||||
{ex ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-[10px]">{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{avatarSrc(m.avatar) ? (
|
||||
<img src={avatarSrc(m.avatar)} className="w-7 h-7 rounded-full object-cover border border-amber-400/60" alt="" onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-xs">{m.name?.[0] || '创'}</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<button type="button" className="text-white text-sm truncate block max-w-[120px] hover:text-[#38bdac] hover:underline text-left" onClick={() => setUserDetailId(m.id)} title="查看用户详情">{m.name}</button>
|
||||
{m.vipRole && <span className="text-[10px] text-gray-500 truncate block max-w-[120px]">{m.vipRole}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<button type="button" className={`p-0.5 rounded ${m.isPinned ? 'text-amber-400' : 'text-gray-600 hover:text-gray-400'}`} onClick={() => togglePin(m)} title={m.isPinned ? '取消置顶' : '置顶到首页'}>
|
||||
<Pin className={`w-3.5 h-3.5 ${m.isPinned ? 'fill-amber-400' : ''}`} />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<button type="button" className={`text-xs font-mono ${(m.leadCount ?? 0) > 0 ? 'text-green-400 hover:text-green-300 hover:underline' : 'text-gray-600'}`}
|
||||
onClick={() => { if ((m.leadCount ?? 0) > 0) openLeadDetail(m) }}
|
||||
title={(m.leadCount ?? 0) > 0 ? '点击查看获客详情' : '暂无获客'}
|
||||
>{m.leadCount ?? 0}</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-blue-400 text-xs font-mono">{m.clickCount ?? 0}</TableCell>
|
||||
<TableCell className="text-center align-middle">
|
||||
{!hasPlan ? (
|
||||
<span className="text-[10px] text-gray-600">—</span>
|
||||
) : !m.personId ? (
|
||||
<Badge className="bg-green-500/15 text-green-400 border-0 text-[10px]">已配置</Badge>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
{(() => {
|
||||
const h = planHealthByPerson[m.personId]
|
||||
if (!h) {
|
||||
return (
|
||||
<Badge className="bg-amber-500/12 text-amber-300 border-0 text-[10px]" title="点击右侧图标检测存客宝连通性">
|
||||
未检测
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (h.status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-0 text-[10px]">在线</Badge>
|
||||
}
|
||||
if (h.status === 'error') {
|
||||
return (
|
||||
<Badge className="bg-red-500/15 text-red-400 border-0 text-[10px] max-w-[4.5rem] truncate" title={h.error || '错误'}>
|
||||
异常
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge className="bg-gray-500/15 text-gray-400 border-0 text-[10px]">未知</Badge>
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-500 hover:text-emerald-400 p-0.5 disabled:opacity-40"
|
||||
title="检测该人物存客宝计划"
|
||||
disabled={planCheckLoading || planCheckRowPid === m.personId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void checkOneCkbPlan(m.personId!)
|
||||
}}
|
||||
>
|
||||
<Stethoscope className={`w-3 h-3 ${planCheckRowPid === m.personId ? 'animate-pulse text-emerald-400' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="inline-flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-[#38bdac] hover:text-[#38bdac]/80" onClick={() => setExpandedId(ex ? null : m.id)} title="展开配置"><Settings2 className="w-3 h-3" /></Button>
|
||||
{m.personId && <Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-gray-400 hover:text-[#38bdac]" onClick={() => openEditPerson(m)} title="编辑"><Pencil className="w-3 h-3" /></Button>}
|
||||
{m.personId && <Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-gray-500 hover:text-red-400" onClick={() => setDeleteTarget(m)} title="删除"><X className="w-2.5 h-2.5" /></Button>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{ex && (
|
||||
<TableRow key={`${m.id}-ex`} className="border-gray-700/30">
|
||||
<TableCell colSpan={8} className="p-0">
|
||||
<ExpandedPanel
|
||||
member={m} onSaved={load} onCopyToken={copyToken}
|
||||
onEditRole={() => { 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) }}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 标签弹窗 */}
|
||||
<Dialog open={showRoleModal} onOpenChange={o => { setShowRoleModal(o); if (!o) setRoleTarget(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Crown className="w-5 h-5 text-amber-400" /> 标签 — {roleTarget?.name}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex flex-wrap gap-2">{VIP_ROLE_PRESETS.map(p => <Button key={p} variant={roleInput === p ? 'default' : 'outline'} size="sm" className={roleInput === p ? 'bg-[#38bdac] text-white' : 'border-gray-600 text-gray-300'} onClick={() => setRoleInput(p)}>{p}</Button>)}</div>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="或手动输入" value={roleInput} onChange={e => setRoleInput(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRoleModal(false)} className="border-gray-600 text-gray-300 bg-transparent"><X className="w-4 h-4 mr-1" /> 取消</Button>
|
||||
<Button onClick={saveRole} disabled={roleSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-1" /> {roleSaving ? '...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 排序弹窗 */}
|
||||
<Dialog open={showSortModal} onOpenChange={o => { setShowSortModal(o); if (!o) setSortTarget(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><ArrowUpDown className="w-5 h-5 text-[#38bdac]" /> 排序 — {sortTarget?.name}</DialogTitle></DialogHeader>
|
||||
<div className="py-3"><Input type="number" className="bg-[#0a1628] border-gray-700 text-white" placeholder="数字越小越靠前" value={sortInput} onChange={e => setSortInput(e.target.value)} /></div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSortModal(false)} className="border-gray-600 text-gray-300 bg-transparent">取消</Button>
|
||||
<Button onClick={saveSort} disabled={sortSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">{sortSaving ? '...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Webhook 弹窗 */}
|
||||
<Dialog open={showWebhookModal} onOpenChange={o => { setShowWebhookModal(o); if (!o) setWebhookTarget(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Webhook className="w-5 h-5 text-[#38bdac]" /> 飞书群 Webhook — {webhookTarget?.name}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-3">
|
||||
<p className="text-xs text-gray-400">用户点击头像留资后,推送消息到此 Webhook 地址(留空则走系统默认)。</p>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." value={webhookInput} onChange={e => setWebhookInput(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowWebhookModal(false)} className="border-gray-600 text-gray-300 bg-transparent">取消</Button>
|
||||
<Button onClick={saveWebhook} disabled={webhookSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">{webhookSaving ? '...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加/编辑@人物 */}
|
||||
<PersonAddEditModal
|
||||
open={personModalOpen}
|
||||
onOpenChange={setPersonModalOpen}
|
||||
editingPerson={editingPersonData}
|
||||
onSubmit={handlePersonSubmit}
|
||||
/>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={o => { if (!o) setDeleteTarget(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||
<DialogHeader><DialogTitle className="text-white">确认删除</DialogTitle></DialogHeader>
|
||||
<p className="text-sm text-gray-300 py-2">
|
||||
确定删除「{deleteTarget?.name}」的@人物记录?
|
||||
{deleteTarget?.personSource !== 'vip_sync' && <span className="text-red-400 text-xs block mt-1">将同时删除存客宝对应获客计划,不可恢复。</span>}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} className="border-gray-600 text-gray-300 bg-transparent">取消</Button>
|
||||
<Button onClick={confirmDeletePerson} className="bg-red-500 hover:bg-red-600 text-white">确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 用户详情弹窗 */}
|
||||
<UserDetailModal open={!!userDetailId} onClose={() => setUserDetailId(null)} userId={userDetailId ?? null} onUserUpdated={load} />
|
||||
|
||||
{/* 获客详情弹窗 */}
|
||||
<Dialog open={leadDetailOpen} onOpenChange={setLeadDetailOpen}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle className="text-white">获客详情 — {leadDetailName}</DialogTitle></DialogHeader>
|
||||
{leadDetailLoading ? (
|
||||
<div className="py-8 text-center text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" /> 加载中...</div>
|
||||
) : leadDetailRecords.length === 0 ? (
|
||||
<p className="py-8 text-center text-gray-500">暂无获客记录</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{leadDetailRecords.map((r: any, i: number) => (
|
||||
<div key={r.id || i} className="flex items-center justify-between p-2 rounded bg-[#0a1628] border border-gray-700/40">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-[#38bdac]/15 flex items-center justify-center text-[10px] text-[#38bdac]">{(r.nickname || r.name || '?')[0]}</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-white text-xs block truncate">{r.nickname || r.name || '—'}</span>
|
||||
<span className="text-[10px] text-gray-500">{r.phone || r.wechatId || '无联系方式'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 shrink-0">{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 展开面板: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<GiftRow[]>(
|
||||
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 <div className="px-6 py-4 bg-[#0a1628] text-gray-500 text-sm">该会员尚未关联@人物,首页入口配置不可用。</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 bg-[#0a1628] space-y-4">
|
||||
{/* 快捷操作行 + Token */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">Token:</span>
|
||||
<code className="text-gray-400 font-mono bg-[#050f1c] px-2 py-0.5 rounded text-[10px] select-all">{m.personToken}</code>
|
||||
<button type="button" className="text-gray-500 hover:text-[#38bdac]" onClick={() => onCopyToken(m.personToken || '')} title="复制"><Copy className="w-3 h-3" /></button>
|
||||
{m.isPinned && <Badge className="bg-amber-500/20 text-amber-400 border-0 text-[10px]">当前置顶</Badge>}
|
||||
{m.personSource === 'vip_sync' && <Badge className="bg-purple-500/15 text-purple-300 border-0 text-[10px]">同步</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-6 text-[10px] border-gray-700 text-gray-300 bg-transparent px-2" onClick={onEditRole}>
|
||||
<Tag className="w-3 h-3 mr-1" /> {m.vipRole || '设标签'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-[10px] border-gray-700 text-gray-300 bg-transparent px-2" onClick={onEditSort}>
|
||||
<ArrowUpDown className="w-3 h-3 mr-1" /> 排序 {m.vipSort ?? '—'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className={`h-6 text-[10px] border-gray-700 bg-transparent px-2 ${m.webhookUrl?.trim() ? 'text-[#38bdac]' : 'text-gray-500'}`} onClick={onEditWebhook}>
|
||||
<Webhook className="w-3 h-3 mr-1" /> {m.webhookUrl?.trim() ? '已配置' : 'Webhook'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* 打赏 */}
|
||||
<div className="space-y-2 p-3 rounded-lg border border-gray-700/40 bg-[#0f2137]/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-300 font-medium flex items-center gap-1"><Gift className="w-3.5 h-3.5 text-[#38bdac]" /> 打赏弹窗</span>
|
||||
<Switch checked={rewardEnabled} onCheckedChange={setRewardEnabled} />
|
||||
</div>
|
||||
{rewardEnabled && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Fld label="入口文案" value={entryText} onChange={setEntryText} />
|
||||
<Fld label="弹窗标题" value={modalTitle} onChange={setModalTitle} />
|
||||
</div>
|
||||
<Fld label="副标题" value={modalSub} onChange={setModalSub} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Fld label="支付按钮" value={payBtn} onChange={setPayBtn} />
|
||||
<Fld label="成功提示" value={afterToast} onChange={setAfterToast} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-gray-500">礼物列表</span>
|
||||
<button type="button" className="text-[10px] text-[#38bdac] hover:underline"
|
||||
onClick={() => setGifts(r => [...r, { id: `gift_${Date.now()}`, name: '新礼物', price: 1, emoji: '🎁', icon: '' }])}>+ 添加</button>
|
||||
</div>
|
||||
{gifts.map((g, i) => (
|
||||
<div key={`${g.id}-${i}`} className="flex items-center gap-1 mb-1">
|
||||
<Input className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 w-12" value={g.emoji} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, emoji: e.target.value } : x))} />
|
||||
<Input className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 flex-1" value={g.name} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<Input type="number" className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 w-16" value={g.price} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, price: parseFloat(e.target.value) || 0 } : x))} />
|
||||
<button type="button" className="text-red-400 hover:text-red-300 p-0.5" onClick={() => setGifts(r => r.filter((_, j) => j !== i))} disabled={gifts.length <= 1}><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 上麦 */}
|
||||
<div className="space-y-2 p-3 rounded-lg border border-gray-700/40 bg-[#0f2137]/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-300 font-medium">🎤 直播时段 · 上麦</span>
|
||||
<Switch checked={micOn} onCheckedChange={setMicOn} />
|
||||
</div>
|
||||
{micOn && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Fld label="开始" value={micStart} onChange={setMicStart} placeholder="18:00" />
|
||||
<Fld label="结束" value={micEnd} onChange={setMicEnd} placeholder="21:00" />
|
||||
<Fld label="图标" value={micEmoji} onChange={setMicEmoji} />
|
||||
</div>
|
||||
<Fld label="按钮文案" value={micBtn} onChange={setMicBtn} />
|
||||
<div>
|
||||
<span className="text-[10px] text-gray-500">上麦支付 Webhook(可选)</span>
|
||||
<Input className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 font-mono" placeholder="https://..." value={micWh} onChange={e => setMicWh(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end"><Button size="sm" onClick={save} disabled={saving} className="bg-[#38bdac] hover:bg-[#2da396] text-white text-xs h-7"><Save className="w-3 h-3 mr-1" /> {saving ? '保存中...' : '保存配置'}</Button></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fld({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[10px] text-gray-500">{label}</span>
|
||||
<Input className="bg-[#050f1c] border-gray-700 text-white text-xs h-7" value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/leads/ckbleadspanel.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/homeentryconfigtab.tsx","./src/pages/users/superindividualtab.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
|
||||
@@ -28,6 +28,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
'/uploads': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||
// MBTI 等静态资源由 soul-api 的 r.Static("/static", "./static") 提供;开发时同源走 5174 会 404,需转发
|
||||
'/static': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -232,6 +232,11 @@ func ensurePersonSchema(db *gorm.DB) {
|
||||
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.Person{}, "home_entry_config") {
|
||||
if err := db.Exec("ALTER TABLE persons ADD COLUMN home_entry_config TEXT NULL COMMENT '首页入口配置(打赏/上麦,JSON)'").Error; err != nil {
|
||||
log.Printf("database: persons schema ensure warning: %v; action=add home_entry_config", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureCkbLeadSchema(db *gorm.DB) {
|
||||
|
||||
@@ -86,3 +86,60 @@ func AdminMbtiAvatarsPost(c *gin.Context) {
|
||||
_mbtiAvatarCacheTs = 0
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "MBTI 头像映射已保存"})
|
||||
}
|
||||
|
||||
// AdminMbtiAvatarsBatchAssign POST /api/admin/mbti-avatars/batch-assign
|
||||
// 为无头像(avatar IS NULL 或空)的用户批量分配 MBTI 默认头像。
|
||||
// 有 MBTI 的按 MBTI 匹配;无 MBTI 的随机分配。
|
||||
func AdminMbtiAvatarsBatchAssign(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先配置 MBTI 头像映射"})
|
||||
return
|
||||
}
|
||||
avatarMap := make(map[string]string)
|
||||
if err := json.Unmarshal(row.ConfigValue, &avatarMap); err != nil || len(avatarMap) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "MBTI 头像映射为空"})
|
||||
return
|
||||
}
|
||||
allTypes := make([]string, 0, len(avatarMap))
|
||||
for k, v := range avatarMap {
|
||||
if v != "" {
|
||||
allTypes = append(allTypes, k)
|
||||
}
|
||||
}
|
||||
if len(allTypes) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无可用头像"})
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
db.Where("(avatar IS NULL OR avatar = '')").Limit(500).Find(&users)
|
||||
if len(users) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "assigned": 0, "message": "无需分配"})
|
||||
return
|
||||
}
|
||||
|
||||
assigned := 0
|
||||
for i := range users {
|
||||
mbti := ""
|
||||
if users[i].Mbti != nil {
|
||||
mbti = *users[i].Mbti
|
||||
}
|
||||
url := ""
|
||||
if mbti != "" {
|
||||
url = avatarMap[mbti]
|
||||
}
|
||||
if url == "" {
|
||||
idx := (int(users[i].CreatedAt.UnixNano()) + i) % len(allTypes)
|
||||
url = avatarMap[allTypes[idx]]
|
||||
}
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", users[i].ID).Update("avatar", url).Error; err == nil {
|
||||
assigned++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "assigned": assigned, "total": len(users), "message": "批量分配完成"})
|
||||
}
|
||||
|
||||
@@ -374,6 +374,35 @@ func DBUsersJourneyStats(c *gin.Context) {
|
||||
db.Table("users").Where("referral_code IS NOT NULL AND referral_code != '' AND earnings > 0").Count(&dist)
|
||||
stats["distribution"] = dist
|
||||
|
||||
// 打赏/上麦礼遇购买人数
|
||||
var tipPay int64
|
||||
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip").Distinct("user_id").Count(&tipPay)
|
||||
stats["tip_pay"] = tipPay
|
||||
|
||||
// 余额充值购买人数
|
||||
var recharge int64
|
||||
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge").Distinct("user_id").Count(&recharge)
|
||||
stats["balance_recharge"] = recharge
|
||||
|
||||
// 匹配次数购买人数
|
||||
var matchPay int64
|
||||
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match").Distinct("user_id").Count(&matchPay)
|
||||
stats["match_pay"] = matchPay
|
||||
|
||||
// 流量池:时间窗内活跃(有任意轨迹的去重用户)
|
||||
now := time.Now()
|
||||
since7 := now.AddDate(0, 0, -7)
|
||||
since30 := now.AddDate(0, 0, -30)
|
||||
var active7, active30 int64
|
||||
_ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since7).Scan(&active7).Error
|
||||
_ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since30).Scan(&active30).Error
|
||||
stats["active_7d"] = active7
|
||||
stats["active_30d"] = active30
|
||||
|
||||
var reg7 int64
|
||||
db.Table("users").Where("created_at >= ?", since7).Count(®7)
|
||||
stats["register_7d"] = reg7
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
|
||||
}
|
||||
|
||||
@@ -421,6 +450,18 @@ func DBUsersJourneyUsers(c *gin.Context) {
|
||||
case "distribution":
|
||||
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "tip_pay":
|
||||
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
|
||||
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip")).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "balance_recharge":
|
||||
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
|
||||
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge")).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "match_pay":
|
||||
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
|
||||
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match")).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
|
||||
return
|
||||
|
||||
@@ -155,6 +155,23 @@ func defaultMpUi() gin.H {
|
||||
"superSectionLinkPath": "/pages/match/match",
|
||||
"pickSectionTitle": "精选推荐",
|
||||
"latestSectionTitle": "最新新增",
|
||||
"linkKaruoReward": gin.H{
|
||||
"enabled": false, "entryButtonText": "支持一下", "modalTitle": "打赏支持",
|
||||
"modalSubtitle": "感谢支持,任选礼物与数量", "payButtonText": "立即支付",
|
||||
"leadLinkText": "留下联系方式", "afterPayToast": "支付成功,感谢支持!",
|
||||
"gifts": []interface{}{
|
||||
gin.H{"id": "gift_star", "name": "人气票", "price": 99, "emoji": "🎫", "icon": "", "micOrder": 0, "iconScale": 1, "micFeatured": false},
|
||||
gin.H{"id": "gift_lite", "name": "小心心", "price": 9.9, "emoji": "❤️", "icon": "", "micOrder": 1, "iconScale": 1, "micFeatured": true},
|
||||
gin.H{"id": "gift_super", "name": "嘉年华", "price": 1980, "emoji": "🎡", "icon": "", "micOrder": 2, "iconScale": 1, "micFeatured": false},
|
||||
},
|
||||
},
|
||||
"liveMicSchedule": gin.H{
|
||||
"enabled": false, "micStart": "18:00", "micEnd": "21:00", "micEmoji": "🎤",
|
||||
"micButtonText": "申请上麦", "micModalTitle": "申请上麦",
|
||||
"micModalContent": "当前为直播时段,提交后工作人员将按顺序处理连麦申请。",
|
||||
"micAfterSubmitToast": "支付成功,感谢支持!", "paidWebhookUrl": "",
|
||||
"micCenterEmojiScale": 1.55,
|
||||
},
|
||||
},
|
||||
"myPage": gin.H{
|
||||
"cardLabel": "名片", "vipLabelVip": "会员中心", "vipLabelGuest": "成为会员",
|
||||
@@ -310,6 +327,19 @@ func buildMiniprogramConfig() gin.H {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 全书解锁按钮显示阈值(购买多少小节后展示「解锁全书」),默认 2
|
||||
var thresholdRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "fullbook_show_threshold").First(&thresholdRow).Error; err == nil && len(thresholdRow.ConfigValue) > 0 {
|
||||
var tv interface{}
|
||||
if json.Unmarshal(thresholdRow.ConfigValue, &tv) == nil {
|
||||
if n, ok := tv.(float64); ok && n > 0 {
|
||||
out["fullbookShowThreshold"] = int(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := out["fullbookShowThreshold"]; !exists {
|
||||
out["fullbookShowThreshold"] = 2
|
||||
}
|
||||
// 好友优惠与分润(用于 read 页展示优惠价、分享提示分润比例)
|
||||
var refRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
|
||||
@@ -1012,7 +1042,7 @@ func DBUsersList(c *gin.Context) {
|
||||
q := db.Model(&model.User{})
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||||
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR COALESCE(vip_name,'') LIKE ? OR COALESCE(wechat_id,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
if poolFilter == "complete" {
|
||||
q = q.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||||
@@ -1027,7 +1057,7 @@ func DBUsersList(c *gin.Context) {
|
||||
query := db.Model(&model.User{})
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||||
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR COALESCE(vip_name,'') LIKE ? OR COALESCE(wechat_id,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
if poolFilter == "complete" {
|
||||
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||||
|
||||
@@ -804,24 +804,45 @@ func CKBPinnedPerson(c *gin.Context) {
|
||||
avatar = resolveAvatarURL(getStringValue(u.Avatar))
|
||||
}
|
||||
}
|
||||
// 返回 per-person 首页入口配置(置顶超级个体独立配置),小程序优先读此字段
|
||||
var homeEntryConfig interface{}
|
||||
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
|
||||
var hec interface{}
|
||||
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
|
||||
homeEntryConfig = hec
|
||||
}
|
||||
}
|
||||
data := gin.H{
|
||||
"name": personName,
|
||||
"avatar": avatar,
|
||||
"token": strings.TrimSpace(p.Token),
|
||||
}
|
||||
if homeEntryConfig != nil {
|
||||
data["homeEntryConfig"] = homeEntryConfig
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"name": personName,
|
||||
"avatar": avatar,
|
||||
"token": strings.TrimSpace(p.Token),
|
||||
},
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
|
||||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态
|
||||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态(尝试启用以探测连通性)
|
||||
// 可选 ?personId=xxx 仅检查单个人物(超级个体行内刷新)
|
||||
func AdminCKBPlanCheck(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var persons []model.Person
|
||||
db.Where("ckb_plan_id > 0").Find(&persons)
|
||||
q := db.Where("ckb_plan_id > 0")
|
||||
if pid := strings.TrimSpace(c.Query("personId")); pid != "" {
|
||||
q = q.Where("person_id = ?", pid)
|
||||
}
|
||||
q.Find(&persons)
|
||||
if len(persons) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"})
|
||||
msg := "暂无配置了存客宝计划的人物"
|
||||
if strings.TrimSpace(c.Query("personId")) != "" {
|
||||
msg = "该人物未配置存客宝计划或不存在"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": msg})
|
||||
return
|
||||
}
|
||||
token, err := ckbOpenGetToken()
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"regexp"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
@@ -21,6 +24,13 @@ import (
|
||||
const giftPayExpireHours = 24
|
||||
const wechatAttachMaxBytes = 128
|
||||
|
||||
var wxOpenIDRegex = regexp.MustCompile(`^o[A-Za-z0-9_-]{26,}$`)
|
||||
|
||||
func isLikelyWechatMiniOpenID(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return wxOpenIDRegex.MatchString(s)
|
||||
}
|
||||
|
||||
// truncateStr 截断字符串至最多 n 字节(UTF-8 安全)
|
||||
func truncateStr(s string, n int) string {
|
||||
b := []byte(s)
|
||||
@@ -207,6 +217,17 @@ func GiftPayInitiatorPay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
req.OpenID = strings.TrimSpace(req.OpenID)
|
||||
if req.OpenID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
if !isLikelyWechatMiniOpenID(req.OpenID) {
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "支付标识无效,请重新进入小程序或重新登录"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -26,6 +27,40 @@ var (
|
||||
orderPollLoggerOnce sync.Once
|
||||
)
|
||||
|
||||
// normalizePayProductType 清洗 productType,避免不可见字符导致未命中 link_karuo_tip 分支
|
||||
func normalizePayProductType(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
s = strings.ReplaceAll(s, "\ufeff", "")
|
||||
s = strings.ReplaceAll(s, "\u00a0", "")
|
||||
s = strings.ReplaceAll(s, "\u200b", "")
|
||||
s = strings.ReplaceAll(s, "\u200c", "")
|
||||
s = strings.ReplaceAll(s, "\u200d", "")
|
||||
s = strings.ReplaceAll(s, "\uff3f", "_")
|
||||
s = strings.ReplaceAll(s, "-", "_")
|
||||
s = strings.ToLower(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.ReplaceAll(out, "_", "") == "linkkaruotip" {
|
||||
return "link_karuo_tip"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isLinkKaruoTipProductType 打赏/上麦商品类型(兼容 normalize 漏网、同形连接符等)
|
||||
func isLinkKaruoTipProductType(s string) bool {
|
||||
s = strings.TrimSpace(strings.ToLower(normalizePayProductType(s)))
|
||||
if s == "link_karuo_tip" {
|
||||
return true
|
||||
}
|
||||
compact := strings.ReplaceAll(strings.ReplaceAll(s, "_", ""), "-", "")
|
||||
return compact == "linkkaruotip"
|
||||
}
|
||||
|
||||
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log,不输出到控制台
|
||||
func orderPollLogf(format string, args ...interface{}) {
|
||||
orderPollLoggerOnce.Do(func() {
|
||||
@@ -377,6 +412,7 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
TipSource string `json:"tipSource"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -384,6 +420,11 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.ProductType = normalizePayProductType(req.ProductType)
|
||||
if isLinkKaruoTipProductType(req.ProductType) {
|
||||
req.ProductType = "link_karuo_tip"
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
|
||||
return
|
||||
@@ -391,6 +432,11 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
|
||||
db := database.DB()
|
||||
|
||||
productID := strings.TrimSpace(req.ProductID)
|
||||
if req.ProductType == "link_karuo_tip" && strings.TrimSpace(req.TipSource) == "live_mic" && productID != "" && !strings.HasPrefix(productID, "live_mic|") {
|
||||
productID = "live_mic|" + productID
|
||||
}
|
||||
|
||||
var finalAmount float64
|
||||
var orderSn string
|
||||
var referrerID *string
|
||||
@@ -410,44 +456,66 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
// -------- V1.1 后端价格:从 DB 读取标准价 --------
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
|
||||
pricePID := productID
|
||||
if pricePID == "" {
|
||||
pricePID = strings.TrimSpace(req.ProductID)
|
||||
}
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID)
|
||||
if priceErr != nil && req.ProductType == "link_karuo_tip" {
|
||||
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount); ferr == nil {
|
||||
standardPrice = fb
|
||||
priceErr = nil
|
||||
_, qty, qe := parseLinkKaruoGiftQty(pricePID)
|
||||
if qe != nil {
|
||||
qty = 1
|
||||
}
|
||||
if strings.TrimSpace(req.TipSource) == "live_mic" {
|
||||
productID = "live_mic|" + canonID + "|" + strconv.FormatInt(qty, 10)
|
||||
} else {
|
||||
productID = canonID + "|" + strconv.FormatInt(qty, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
finalAmount = standardPrice
|
||||
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
// 打赏不参与分销好友折扣(实付=标准价)
|
||||
if req.ProductType != "link_karuo_tip" {
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = finalAmount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = finalAmount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,26 +523,12 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
|
||||
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
|
||||
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
|
||||
req.Amount, finalAmount, req.ProductType, pricePID, req.UserID)
|
||||
}
|
||||
orderSn = wechat.GenerateOrderSn()
|
||||
}
|
||||
|
||||
totalFee := int(finalAmount * 100) // 转为分
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
if req.ProductType == "balance_recharge" {
|
||||
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
||||
} else if req.ProductType == "fullbook" {
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
} else if req.ProductType == "vip" {
|
||||
description = "卡若创业派对VIP年度会员(365天)"
|
||||
} else if req.ProductType == "match" {
|
||||
description = "购买匹配次数"
|
||||
} else {
|
||||
description = fmt.Sprintf("章节购买-%s", req.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户端 IP
|
||||
clientIP := c.ClientIP()
|
||||
@@ -489,21 +543,64 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
|
||||
userID = u.ID
|
||||
} else {
|
||||
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
if req.ProductType != "balance_recharge" {
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "match":
|
||||
productID = "match"
|
||||
case "link_karuo_tip":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "打赏缺少礼物标识"})
|
||||
return
|
||||
default:
|
||||
productID = "fullbook"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(req.Description)
|
||||
if description == "" {
|
||||
switch req.ProductType {
|
||||
case "balance_recharge":
|
||||
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
||||
case "fullbook":
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
description = "卡若创业派对VIP年度会员(365天)"
|
||||
case "match":
|
||||
productID = "match"
|
||||
description = "购买匹配次数"
|
||||
case "link_karuo_tip":
|
||||
root := loadMpConfigRoot(db)
|
||||
giftID := productID
|
||||
if strings.HasPrefix(giftID, "live_mic|") {
|
||||
parts := strings.Split(giftID, "|")
|
||||
if len(parts) >= 2 {
|
||||
giftID = strings.TrimSpace(parts[1])
|
||||
}
|
||||
} else {
|
||||
parts := strings.Split(giftID, "|")
|
||||
giftID = strings.TrimSpace(parts[0])
|
||||
}
|
||||
g, _ := findLinkKaruoGift(root, giftID)
|
||||
name := "打赏"
|
||||
if g != nil {
|
||||
if n, ok := g["name"].(string); ok && strings.TrimSpace(n) != "" {
|
||||
name = strings.TrimSpace(n)
|
||||
}
|
||||
}
|
||||
if strings.Contains(productID, "live_mic|") {
|
||||
description = "上麦礼遇 · " + name
|
||||
} else {
|
||||
description = "首页打赏 · " + name
|
||||
}
|
||||
default:
|
||||
productID = "fullbook"
|
||||
description = fmt.Sprintf("章节购买-%s", productID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +627,29 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
|
||||
type payAttach struct {
|
||||
ProductType string `json:"pt"`
|
||||
ProductID string `json:"pid,omitempty"`
|
||||
UserID string `json:"uid,omitempty"`
|
||||
TipSource string `json:"ts,omitempty"`
|
||||
}
|
||||
attachBytes, _ := json.Marshal(payAttach{
|
||||
ProductType: req.ProductType,
|
||||
ProductID: productID,
|
||||
UserID: userID,
|
||||
TipSource: strings.TrimSpace(req.TipSource),
|
||||
})
|
||||
attach := string(attachBytes)
|
||||
if len(attach) > 128 {
|
||||
shortAttach, _ := json.Marshal(payAttach{
|
||||
ProductType: req.ProductType,
|
||||
UserID: userID,
|
||||
})
|
||||
attach = string(shortAttach)
|
||||
if len(attach) > 128 {
|
||||
attach = fmt.Sprintf(`{"pt":"%s"}`, req.ProductType)
|
||||
}
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach)
|
||||
if err != nil {
|
||||
@@ -623,11 +742,13 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
TipSource string `json:"tipSource"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
|
||||
PT string `json:"pt"`
|
||||
PID string `json:"pid"`
|
||||
UID string `json:"uid"`
|
||||
TS string `json:"ts"`
|
||||
SN string `json:"sn"`
|
||||
IP int `json:"ip"`
|
||||
}
|
||||
@@ -646,6 +767,9 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
if attach.UserID == "" {
|
||||
attach.UserID = attach.UID
|
||||
}
|
||||
if attach.TipSource == "" {
|
||||
attach.TipSource = attach.TS
|
||||
}
|
||||
if attach.GiftPayRequestSn == "" {
|
||||
attach.GiftPayRequestSn = attach.SN
|
||||
}
|
||||
@@ -787,6 +911,8 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||||
}
|
||||
} else if attach.ProductType == "link_karuo_tip" {
|
||||
fmt.Printf("[PayNotify] 打赏/上麦礼遇订单完成: user=%s order=%s\n", beneficiaryUserID, orderSn)
|
||||
}
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
@@ -824,6 +950,10 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
|
||||
// 处理分销佣金(会员订单 20%/10%,内容订单 90%)
|
||||
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
|
||||
if order != nil && order.ProductType == "link_karuo_tip" {
|
||||
fmt.Printf("[PayNotify] 打赏订单跳过分销佣金: orderSn=%s\n", orderSn)
|
||||
return
|
||||
}
|
||||
type Binding struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
@@ -1203,13 +1333,241 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
|
||||
}
|
||||
case "balance_recharge":
|
||||
ConfirmBalanceRechargeByOrder(db, order)
|
||||
case "link_karuo_tip":
|
||||
// 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益
|
||||
}
|
||||
}
|
||||
|
||||
// loadMpConfigRoot 读取 system_config.mp_config 原始 JSON(用于礼物价、上麦 Webhook 等)
|
||||
func loadMpConfigRoot(db *gorm.DB) map[string]interface{} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseMoneyAny(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
if err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func defaultLinkKaruoGifts() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"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},
|
||||
}
|
||||
}
|
||||
|
||||
func linkKaruoRewardSection(root map[string]interface{}) map[string]interface{} {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
mpUi, _ := root["mpUi"].(map[string]interface{})
|
||||
if mpUi == nil {
|
||||
return nil
|
||||
}
|
||||
home, _ := mpUi["homePage"].(map[string]interface{})
|
||||
if home == nil {
|
||||
return nil
|
||||
}
|
||||
rw, _ := home["linkKaruoReward"].(map[string]interface{})
|
||||
return rw
|
||||
}
|
||||
|
||||
func linkKaruoGiftsList(root map[string]interface{}) []map[string]interface{} {
|
||||
sec := linkKaruoRewardSection(root)
|
||||
if sec == nil {
|
||||
return defaultLinkKaruoGifts()
|
||||
}
|
||||
raw, ok := sec["gifts"]
|
||||
if !ok || raw == nil {
|
||||
return defaultLinkKaruoGifts()
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return defaultLinkKaruoGifts()
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return defaultLinkKaruoGifts()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||||
giftID = strings.TrimSpace(giftID)
|
||||
if giftID == "" {
|
||||
return nil, 0
|
||||
}
|
||||
for _, g := range linkKaruoGiftsList(root) {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == giftID || strings.EqualFold(id, giftID) {
|
||||
price := parseMoneyAny(g["price"])
|
||||
if price > 0 {
|
||||
return g, price
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
const linkKaruoAmountEpsilon = 0.051
|
||||
|
||||
// parseLinkKaruoGiftQty 解析 giftId|qty 或 live_mic|giftId|qty
|
||||
func parseLinkKaruoGiftQty(productID string) (giftID string, qty int64, err error) {
|
||||
s := strings.TrimSpace(productID)
|
||||
parts := strings.Split(s, "|")
|
||||
if len(parts) >= 2 && strings.TrimSpace(parts[0]) == "live_mic" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return "", 0, fmt.Errorf("打赏商品格式错误")
|
||||
}
|
||||
giftID = strings.TrimSpace(parts[0])
|
||||
qty = 1
|
||||
if len(parts) >= 2 {
|
||||
q, e := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if e != nil || q < 1 || q > 99 {
|
||||
return "", 0, fmt.Errorf("打赏数量无效")
|
||||
}
|
||||
qty = q
|
||||
}
|
||||
return giftID, qty, nil
|
||||
}
|
||||
|
||||
// linkKaruoTipFallbackByAmount 客户端礼物 id 与库不一致时,按金额唯一匹配后台档位并返回规范 id
|
||||
func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount float64) (total float64, giftName string, canonicalID string, err error) {
|
||||
if clientAmount <= 0 {
|
||||
return 0, "", "", fmt.Errorf("支付金额无效")
|
||||
}
|
||||
root := loadMpConfigRoot(db)
|
||||
wantID, qty, err := parseLinkKaruoGiftQty(productID)
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
type hit struct {
|
||||
id string
|
||||
unit float64
|
||||
name string
|
||||
}
|
||||
var hits []hit
|
||||
for _, g := range linkKaruoGiftsList(root) {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
unit := parseMoneyAny(g["price"])
|
||||
if unit <= 0 {
|
||||
continue
|
||||
}
|
||||
t := unit * float64(qty)
|
||||
if math.Abs(t-clientAmount) < linkKaruoAmountEpsilon {
|
||||
nm, _ := g["name"].(string)
|
||||
hits = append(hits, hit{id, unit, strings.TrimSpace(nm)})
|
||||
}
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return 0, "", "", fmt.Errorf("打赏金额与后台档位不一致,请同步 mp_config 礼物后重试")
|
||||
}
|
||||
if len(hits) == 1 {
|
||||
h := hits[0]
|
||||
name := h.name
|
||||
if name == "" {
|
||||
name = "礼物"
|
||||
}
|
||||
return h.unit * float64(qty), name, h.id, nil
|
||||
}
|
||||
for _, h := range hits {
|
||||
if strings.EqualFold(h.id, wantID) {
|
||||
name := h.name
|
||||
if name == "" {
|
||||
name = "礼物"
|
||||
}
|
||||
return h.unit * float64(qty), name, h.id, nil
|
||||
}
|
||||
}
|
||||
return 0, "", "", fmt.Errorf("后台存在同价多档礼物,请为每档设置不同价格或唯一 id")
|
||||
}
|
||||
|
||||
// linkKaruoTipPriceTotal 打赏标准价:普通 giftId|qty;上麦 live_mic|giftId|qty
|
||||
func linkKaruoTipPriceTotal(db *gorm.DB, productID string) (float64, error) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("打赏缺少礼物标识")
|
||||
}
|
||||
root := loadMpConfigRoot(db)
|
||||
var giftID string
|
||||
var qty int64 = 1
|
||||
if strings.HasPrefix(productID, "live_mic|") {
|
||||
parts := strings.Split(productID, "|")
|
||||
if len(parts) < 2 {
|
||||
return 0, fmt.Errorf("上麦礼物参数无效")
|
||||
}
|
||||
giftID = strings.TrimSpace(parts[1])
|
||||
if len(parts) >= 3 {
|
||||
if q, err := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64); err == nil && q > 0 {
|
||||
qty = q
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts := strings.Split(productID, "|")
|
||||
giftID = strings.TrimSpace(parts[0])
|
||||
if len(parts) >= 2 {
|
||||
if q, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil && q > 0 {
|
||||
qty = q
|
||||
}
|
||||
}
|
||||
}
|
||||
_, price := findLinkKaruoGift(root, giftID)
|
||||
if price <= 0 {
|
||||
return 0, fmt.Errorf("礼物不存在或未定价: %s", giftID)
|
||||
}
|
||||
return price * float64(qty), nil
|
||||
}
|
||||
|
||||
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
|
||||
// productType: fullbook / vip / section / match
|
||||
// productId: 章节购买时为章节 ID
|
||||
// productType: fullbook / vip / section / match / link_karuo_tip
|
||||
// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty
|
||||
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
|
||||
productType = normalizePayProductType(productType)
|
||||
// 打赏/上麦:显式分支 + 模糊识别双保险,避免误入 default 报「未知商品类型」
|
||||
if productType == "link_karuo_tip" || isLinkKaruoTipProductType(productType) {
|
||||
return linkKaruoTipPriceTotal(db, productID)
|
||||
}
|
||||
switch productType {
|
||||
case "fullbook", "vip", "match":
|
||||
// 从 system_config 读取
|
||||
|
||||
39
soul-api/internal/handler/miniprogram_super_stats.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MiniprogramMySuperStats GET /api/miniprogram/my/super-stats?userId=
|
||||
// 返回当前 VIP 用户的超级个体获客数和头像点击数(小程序「我的」页展示用)。
|
||||
func MiniprogramMySuperStats(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Query("userId"))
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
clicks := batchSuperIndividualClicks(db, []string{userID})
|
||||
leads := batchSuperIndividualLeads(db, []string{userID})
|
||||
|
||||
var person model.Person
|
||||
hasPerson := db.Where("user_id = ?", userID).First(&person).Error == nil
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"clickCount": clicks[userID],
|
||||
"leadCount": leads[userID],
|
||||
"hasPerson": hasPerson,
|
||||
"isPinned": hasPerson && person.IsPinned,
|
||||
"personName": person.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,41 @@ func loadOrderWebhookURL(db *gorm.DB) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadLiveMicTipPaidWebhookURL(db *gorm.DB, order *model.Order) string {
|
||||
if order == nil || order.ProductType != "link_karuo_tip" {
|
||||
return ""
|
||||
}
|
||||
pid := ""
|
||||
if order.ProductID != nil {
|
||||
pid = strings.TrimSpace(*order.ProductID)
|
||||
}
|
||||
if !strings.HasPrefix(pid, "live_mic|") {
|
||||
return ""
|
||||
}
|
||||
root := loadMpConfigRoot(db)
|
||||
if root == nil {
|
||||
return ""
|
||||
}
|
||||
mpUi, _ := root["mpUi"].(map[string]interface{})
|
||||
if mpUi == nil {
|
||||
return ""
|
||||
}
|
||||
home, _ := mpUi["homePage"].(map[string]interface{})
|
||||
if home == nil {
|
||||
return ""
|
||||
}
|
||||
sch, _ := home["liveMicSchedule"].(map[string]interface{})
|
||||
if sch == nil {
|
||||
return ""
|
||||
}
|
||||
u, _ := sch["paidWebhookUrl"].(string)
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" && strings.HasPrefix(u, "http") {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.OrderSN == "" {
|
||||
return fmt.Errorf("empty order")
|
||||
@@ -41,7 +76,10 @@ func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
|
||||
if order.WebhookPushStatus == "sent" {
|
||||
return nil
|
||||
}
|
||||
webhookURL := loadOrderWebhookURL(db)
|
||||
webhookURL := loadLiveMicTipPaidWebhookURL(db, order)
|
||||
if webhookURL == "" {
|
||||
webhookURL = loadOrderWebhookURL(db)
|
||||
}
|
||||
if webhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
|
||||
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
|
||||
return 0
|
||||
}
|
||||
if order.ProductType == "link_karuo_tip" {
|
||||
return 0
|
||||
}
|
||||
// 读取推广配置
|
||||
distributorShare := 0.9
|
||||
userDiscount := 0.0
|
||||
|
||||
@@ -243,12 +243,9 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if type_ == "section" && productId != "" {
|
||||
// 章节:需要区分普通版/增值版
|
||||
var ch model.Chapter
|
||||
// 不加载 content,避免大字段
|
||||
_ = db.Select("id", "is_free", "price", "edition_standard", "edition_premium").Where("id = ?", productId).First(&ch).Error
|
||||
_ = db.Select("id", "is_free", "price").Where("id = ?", productId).First(&ch).Error
|
||||
|
||||
// 免费章节:直接可读
|
||||
if ch.ID != "" {
|
||||
if (ch.IsFree != nil && *ch.IsFree) || (ch.Price != nil && *ch.Price == 0) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "free_section"}})
|
||||
@@ -256,16 +253,10 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
isPremium := ch.ID != "" && ch.EditionPremium != nil && *ch.EditionPremium
|
||||
// 默认普通版:未明确标记增值版时,按普通版处理
|
||||
isStandard := !isPremium
|
||||
|
||||
// 普通版:买断可读;增值版:买断不包含
|
||||
if isStandard {
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
// 全书买断覆盖所有章节(不再区分增值版/普通版)
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
@@ -725,6 +716,8 @@ func userTrackActionLabelCN(action string) string {
|
||||
return "页面浏览"
|
||||
case "search":
|
||||
return "搜索"
|
||||
case "live_mic_request":
|
||||
return "申请上麦"
|
||||
default:
|
||||
if action == "" {
|
||||
return "行为"
|
||||
|
||||
@@ -39,8 +39,9 @@ func loadSuperIndividualWebhookMap(db *gorm.DB) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// batchSuperIndividualClicks 统计「点击头像」行为:
|
||||
// user_tracks 中 action=avatar_click(兼容历史 btn_click)且 target 前缀「链接头像_」。
|
||||
// batchSuperIndividualClicks 统计超级个体被点击次数:
|
||||
// 1. member-detail 页 avatar_click / btn_click,target 格式「链接头像_userId」
|
||||
// 2. 首页超级个体横滑 card_click,target 格式「超级个体_userId」
|
||||
func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64 {
|
||||
out := make(map[string]int64)
|
||||
if len(userIDs) == 0 {
|
||||
@@ -52,15 +53,22 @@ func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64
|
||||
}
|
||||
var rows []row
|
||||
_ = db.Raw(`
|
||||
SELECT
|
||||
SUBSTRING(target, 6) AS user_id,
|
||||
COUNT(*) AS clicks
|
||||
FROM user_tracks
|
||||
WHERE action IN ('avatar_click', 'btn_click')
|
||||
AND target LIKE '链接头像\_%'
|
||||
AND SUBSTRING(target, 6) IN ?
|
||||
GROUP BY user_id
|
||||
`, userIDs).Scan(&rows)
|
||||
SELECT user_id, SUM(clicks) AS clicks FROM (
|
||||
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
|
||||
FROM user_tracks
|
||||
WHERE action IN ('avatar_click', 'btn_click')
|
||||
AND target LIKE '链接头像\_%'
|
||||
AND SUBSTRING(target, 6) IN ?
|
||||
GROUP BY user_id
|
||||
UNION ALL
|
||||
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
|
||||
FROM user_tracks
|
||||
WHERE action = 'card_click'
|
||||
AND target LIKE '超级个体\_%'
|
||||
AND SUBSTRING(target, 6) IN ?
|
||||
GROUP BY user_id
|
||||
) t GROUP BY user_id
|
||||
`, userIDs, userIDs).Scan(&rows)
|
||||
for _, r := range rows {
|
||||
if r.UserID != "" {
|
||||
out[r.UserID] = r.Clicks
|
||||
@@ -138,6 +146,18 @@ func DBVipMembersList(c *gin.Context) {
|
||||
leadByUser := batchSuperIndividualLeads(db, ids)
|
||||
webhookMap := loadSuperIndividualWebhookMap(db)
|
||||
|
||||
// 批量查 persons 关联:token, isPinned, homeEntryConfig
|
||||
personByUserID := map[string]*model.Person{}
|
||||
if len(ids) > 0 {
|
||||
var persons []model.Person
|
||||
db.Where("user_id IN ?", ids).Find(&persons)
|
||||
for i := range persons {
|
||||
if persons[i].UserID != nil && *persons[i].UserID != "" {
|
||||
personByUserID[*persons[i].UserID] = &persons[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
item := formatVipMember(db, &users[i], true)
|
||||
@@ -145,6 +165,21 @@ func DBVipMembersList(c *gin.Context) {
|
||||
item["clickCount"] = clickByUser[uid]
|
||||
item["leadCount"] = leadByUser[uid]
|
||||
item["webhookUrl"] = strings.TrimSpace(webhookMap[uid])
|
||||
if p, ok := personByUserID[uid]; ok {
|
||||
item["personId"] = p.PersonID
|
||||
item["personToken"] = p.Token
|
||||
item["isPinned"] = p.IsPinned
|
||||
item["personName"] = p.Name
|
||||
item["personSource"] = p.PersonSource
|
||||
item["ckbPlanId"] = p.CkbPlanID
|
||||
item["ckbApiKey"] = p.CkbApiKey
|
||||
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
|
||||
var hec interface{}
|
||||
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
|
||||
item["homeEntryConfig"] = hec
|
||||
}
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
@@ -246,3 +281,38 @@ func DBVipMemberWebhookSet(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DBPersonHomeEntryConfigSave PUT /api/db/persons/home-entry-config
|
||||
// 保存超级个体的首页入口配置(打赏弹窗 + 上麦时段),存入 persons.home_entry_config。
|
||||
func DBPersonHomeEntryConfigSave(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
HomeEntryConfig interface{} `json:"homeEntryConfig"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(body.UserID)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var person model.Person
|
||||
if err := db.Where("user_id = ?", userID).First(&person).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该用户尚未关联超级个体人物"})
|
||||
return
|
||||
}
|
||||
configJSON, err := json.Marshal(body.HomeEntryConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置序列化失败"})
|
||||
return
|
||||
}
|
||||
s := string(configJSON)
|
||||
if err := db.Model(&model.Person{}).Where("id = ?", person.ID).Update("home_entry_config", s).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -80,18 +80,17 @@ func WsMiniprogram(c *gin.Context) {
|
||||
// 鉴权通过,开始处理心跳
|
||||
conn.SetReadDeadline(time.Time{}) // 取消超时
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
log.Printf("[WS] Redis 未启用,在线状态不可用")
|
||||
return
|
||||
}
|
||||
key := wsOnlinePrefix + userID
|
||||
ctx := context.Background()
|
||||
ttl := time.Duration(wsOfflineTimeout) * time.Second
|
||||
|
||||
// 立即写入一次在线
|
||||
client.Set(ctx, key, "1", ttl)
|
||||
if client != nil {
|
||||
client.Set(ctx, key, "1", ttl)
|
||||
} else {
|
||||
log.Printf("[WS] Redis 未启用,在线统计不可用;仍维持读循环并回复 pong,避免小程序端秒断")
|
||||
}
|
||||
|
||||
// 心跳读取循环
|
||||
// 心跳读取循环(Redis 不可用时仅不回写在线 key)
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -101,8 +100,10 @@ func WsMiniprogram(c *gin.Context) {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(msg, &m) == nil && (m.Type == "ping" || m.Type == "heartbeat") {
|
||||
client.Set(ctx, key, "1", ttl)
|
||||
conn.WriteJSON(map[string]interface{}{"type": "pong"})
|
||||
if client != nil {
|
||||
client.Set(ctx, key, "1", ttl)
|
||||
}
|
||||
_ = conn.WriteJSON(map[string]interface{}{"type": "pong"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type Person struct {
|
||||
UserID *string `gorm:"column:user_id;size:50;uniqueIndex" json:"userId"`
|
||||
|
||||
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
|
||||
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token,文章/小程序传此值
|
||||
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token,文章/小程序传此值
|
||||
Name string `gorm:"column:name;size:100" json:"name"`
|
||||
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
@@ -27,17 +27,20 @@ type Person struct {
|
||||
CkbPlanID int64 `gorm:"column:ckb_plan_id;default:0" json:"ckbPlanId"`
|
||||
|
||||
// 存客宝 API 获客配置缓存(与 PersonAddEditModal 对应)
|
||||
Greeting string `gorm:"column:greeting;size:255;default:''" json:"greeting"`
|
||||
Tips string `gorm:"column:tips;type:text" json:"tips"`
|
||||
RemarkType string `gorm:"column:remark_type;size:50;default:''" json:"remarkType"`
|
||||
RemarkFormat string `gorm:"column:remark_format;size:200;default:''" json:"remarkFormat"`
|
||||
Greeting string `gorm:"column:greeting;size:255;default:''" json:"greeting"`
|
||||
Tips string `gorm:"column:tips;type:text" json:"tips"`
|
||||
RemarkType string `gorm:"column:remark_type;size:50;default:''" json:"remarkType"`
|
||||
RemarkFormat string `gorm:"column:remark_format;size:200;default:''" json:"remarkFormat"`
|
||||
AddFriendInterval int `gorm:"column:add_friend_interval;default:1" json:"addFriendInterval"`
|
||||
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
// 置顶到小程序首页
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
|
||||
|
||||
// 首页入口配置(打赏弹窗 + 上麦时段等),JSON 存储,各超级个体独立配置
|
||||
HomeEntryConfig *string `gorm:"column:home_entry_config;type:text" json:"homeEntryConfig"`
|
||||
|
||||
// PersonSource 来源:空=后台手工添加;vip_sync=超级个体自动同步(共用统一计划)
|
||||
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
uploadDir = "./uploads"
|
||||
}
|
||||
r.Static("/uploads", uploadDir)
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// H5 落地页:朋友圈分享链接,文章预览 + 打开小程序按钮
|
||||
r.GET("/read/:id", handler.H5ReadPage)
|
||||
@@ -122,10 +123,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
|
||||
admin.GET("/mbti-avatars", handler.AdminMbtiAvatarsGet)
|
||||
admin.POST("/mbti-avatars", handler.AdminMbtiAvatarsPost)
|
||||
admin.POST("/mbti-avatars/batch-assign", handler.AdminMbtiAvatarsBatchAssign)
|
||||
admin.GET("/open-platform/keys", handler.AdminOpenPlatformKeysList)
|
||||
admin.POST("/open-platform/keys", handler.AdminOpenPlatformKeysCreate)
|
||||
admin.POST("/open-platform/keys/:id/revoke", handler.AdminOpenPlatformKeyRevoke)
|
||||
admin.GET("/open-platform/logs", handler.AdminOpenPlatformLogsList)
|
||||
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
|
||||
}
|
||||
|
||||
// ----- 开放平台(API Key:Authorization Bearer op_sk_... 或 X-API-Key)-----
|
||||
@@ -210,6 +213,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.DELETE("/users", handler.DBUsersDelete)
|
||||
db.GET("/users/referrals", handler.DBUsersReferrals)
|
||||
db.GET("/users/rfm", handler.DBUsersRFM)
|
||||
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
|
||||
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
|
||||
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
|
||||
db.GET("/users/tracks", handler.DBUserTracksList)
|
||||
@@ -219,6 +223,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.DELETE("/vip-roles", handler.DBVipRolesAction)
|
||||
db.GET("/vip-members", handler.DBVipMembersList)
|
||||
db.PUT("/vip-members/webhook", handler.DBVipMemberWebhookSet)
|
||||
db.PUT("/persons/home-entry-config", handler.DBPersonHomeEntryConfigSave)
|
||||
db.GET("/match-records", handler.DBMatchRecordsList)
|
||||
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
|
||||
db.GET("/mentors", handler.DBMentorsList)
|
||||
@@ -418,6 +423,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/gift-pay/redeem", handler.GiftPayRedeem)
|
||||
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
|
||||
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
|
||||
miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -310,6 +311,11 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int,
|
||||
detail = fmt.Sprintf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
oidHint := openID
|
||||
if len(oidHint) > 10 {
|
||||
oidHint = oidHint[:6] + "…"
|
||||
}
|
||||
log.Printf("[WechatPay] JSAPI prepay_id 空 outTradeNo=%s openId=%s amountFen=%d %s", orderSn, oidHint, amountCents, detail)
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空 (%s)", detail)
|
||||
}
|
||||
return res.PrepayID, nil
|
||||
|
||||
@@ -10,13 +10,15 @@ soulApi(soul-api 后端)Go 项目一键部署到宝塔(正式环境)
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔「网站」里 Go 站点名一致)
|
||||
BT_GO_SITE_ID = 可选,网站 id(不设则从 sites 表自动匹配 Go 站点)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -121,7 +123,8 @@ def run_build(root):
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
DEPLOY_PORT = 9090
|
||||
# 正式环境 Nginx 一般反代 8080;可用环境变量覆盖:DEPLOY_API_PORT=9090
|
||||
DEPLOY_PORT = int(os.environ.get("DEPLOY_API_PORT", "8080"))
|
||||
|
||||
|
||||
def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
@@ -203,8 +206,109 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
# ==================== 宝塔 API 重启 ====================
|
||||
|
||||
|
||||
def _bt_signed_post(base_url, key, path, extra_data):
|
||||
"""单次宝塔签名 POST(每请求独立 request_time/token)。"""
|
||||
req_time = int(time.time())
|
||||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||
data = {"request_time": req_time, "request_token": req_token}
|
||||
data.update(extra_data or {})
|
||||
return requests.post(base_url + path, data=data, timeout=20, verify=False)
|
||||
|
||||
|
||||
def _bt_parse_json_response(r):
|
||||
"""解析面板 JSON(部分响应 Content-Type 不准)。"""
|
||||
if r is None or r.status_code != 200:
|
||||
return None
|
||||
ct = (r.headers.get("content-type") or "").lower()
|
||||
if "json" in ct:
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
pass
|
||||
t = (r.text or "").lstrip()
|
||||
if t.startswith("{"):
|
||||
try:
|
||||
return json.loads(r.text)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _bt_discover_go_site(base, key, want_name):
|
||||
"""从「网站」列表匹配 project_type=Go 的站点(新版面板 go_project 插件表常为空)。"""
|
||||
want = (want_name or "").strip()
|
||||
want_l = want.lower()
|
||||
if not want:
|
||||
return None, None
|
||||
|
||||
def _scan_rows(rows):
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if row.get("project_type") != "Go":
|
||||
continue
|
||||
n = row.get("name") or row.get("ps") or ""
|
||||
path = (row.get("path") or "").lower()
|
||||
rid = row.get("id")
|
||||
if rid is None:
|
||||
continue
|
||||
if n == want or (isinstance(n, str) and n.strip().lower() == want_l):
|
||||
return str(rid), (n or want)
|
||||
if "soul-api" in path and (want_l in path or want_l in (n or "").lower()):
|
||||
return str(rid), (n or want)
|
||||
return None, None
|
||||
|
||||
for search in (want, ""):
|
||||
r = _bt_signed_post(
|
||||
base,
|
||||
key,
|
||||
"/data?action=getData&table=sites",
|
||||
{"p": "1", "limit": "500", "search": search, "type": "-1"},
|
||||
)
|
||||
j = _bt_parse_json_response(r)
|
||||
rows = j.get("data") if isinstance(j, dict) else None
|
||||
if isinstance(rows, list):
|
||||
hit = _scan_rows(rows)
|
||||
if hit[0]:
|
||||
return hit
|
||||
return None, None
|
||||
|
||||
|
||||
def _bt_restart_go_via_site_api(base, key, site_id, site_name):
|
||||
"""/site?action=SiteStop / SiteStart(与面板「网站」一致)。"""
|
||||
print(" [宝塔API] site SiteStop/SiteStart (id=%s) …" % site_id)
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
base,
|
||||
key,
|
||||
"/site?action=SiteStop",
|
||||
{"id": str(site_id), "name": site_name},
|
||||
)
|
||||
)
|
||||
if not isinstance(j, dict) or j.get("status") is not True:
|
||||
if isinstance(j, dict) and j.get("msg"):
|
||||
print(" [宝塔API] SiteStop: %s" % j.get("msg"))
|
||||
return False
|
||||
time.sleep(2)
|
||||
j2 = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
base,
|
||||
key,
|
||||
"/site?action=SiteStart",
|
||||
{"id": str(site_id), "name": site_name},
|
||||
)
|
||||
)
|
||||
if isinstance(j2, dict) and j2.get("status") is True:
|
||||
print(" [成功] 已通过宝塔 API 重启 Go 站点: %s" % site_name)
|
||||
return True
|
||||
if isinstance(j2, dict) and j2.get("msg"):
|
||||
print(" [宝塔API] SiteStart: %s" % j2.get("msg"))
|
||||
return False
|
||||
|
||||
|
||||
def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
"""通过宝塔 API 重启:优先网站型 Go(SiteStop/SiteStart),失败再试 go_project 插件。"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
@@ -214,42 +318,40 @@ def restart_via_bt_api(cfg):
|
||||
print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。pip install requests")
|
||||
return False
|
||||
try:
|
||||
req_time = int(time.time())
|
||||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||
# 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准
|
||||
base = url.rstrip("/")
|
||||
params = {"request_time": req_time, "request_token": req_token}
|
||||
# 常见形式:POST 带 action + 项目标识;部分版本只认 project_name,部分只认 name,故同时传
|
||||
site_id_env = (os.environ.get("BT_GO_SITE_ID") or "").strip()
|
||||
if site_id_env:
|
||||
sid, snm = site_id_env, name
|
||||
else:
|
||||
sid, snm = _bt_discover_go_site(base, key, name)
|
||||
if sid and _bt_restart_go_via_site_api(base, key, sid, snm or name):
|
||||
return True
|
||||
|
||||
# 兜底:go_project 插件(部分旧面板)
|
||||
for action in ("stop_go_project", "start_go_project"):
|
||||
data = dict(params)
|
||||
data["action"] = action
|
||||
data["project_name"] = name
|
||||
data["name"] = name
|
||||
r = requests.post(
|
||||
base + "/plugin?name=go_project",
|
||||
data=data,
|
||||
timeout=15,
|
||||
verify=False,
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
base,
|
||||
key,
|
||||
"/plugin?name=go_project",
|
||||
{"action": action, "project_name": name, "name": name},
|
||||
)
|
||||
)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if action == "stop_go_project":
|
||||
time.sleep(2)
|
||||
if j.get("status") is False and j.get("msg"):
|
||||
if isinstance(j, dict) and j.get("status") is False and j.get("msg"):
|
||||
print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
|
||||
# 再调一次 start 确保启动
|
||||
data = dict(params)
|
||||
data["action"] = "start_go_project"
|
||||
data["project_name"] = name
|
||||
data["name"] = name
|
||||
r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
|
||||
if r.status_code == 200:
|
||||
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if j.get("status") is True:
|
||||
print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name)
|
||||
return True
|
||||
j = _bt_parse_json_response(
|
||||
_bt_signed_post(
|
||||
base,
|
||||
key,
|
||||
"/plugin?name=go_project",
|
||||
{"action": "start_go_project", "project_name": name, "name": name},
|
||||
)
|
||||
)
|
||||
if isinstance(j, dict) and j.get("status") is True:
|
||||
print(" [成功] 已通过宝塔 API 重启 Go 项目(插件): %s" % name)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [宝塔API 失败] %s" % str(e))
|
||||
@@ -374,6 +476,9 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [成功] soulApi 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
if restart_method == "btapi" and not ok:
|
||||
print(" [失败] 已指定 --restart-method btapi,但宝塔 API 重启未成功(请核对 API 白名单含本机出口 IP、BT_GO_PROJECT_NAME/BT_GO_SITE_ID)")
|
||||
return False
|
||||
else:
|
||||
print("[4/4] 跳过重启 (--no-restart)")
|
||||
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
# soul-api 文档索引
|
||||
|
||||
> 相关文档已统一移至 **开发文档**。
|
||||
> 详细文档位于仓库 **[开发文档](../开发文档/)**。以下为常用入口(路径相对 `soul-api/`)。
|
||||
|
||||
- [8、部署/部署总览](../开发文档/8、部署/部署总览.md) — 部署入口
|
||||
- [8、部署/DOCKER部署说明](../开发文档/8、部署/DOCKER部署说明.md)
|
||||
- [8、部署/宝塔-Docker首次配置指南](../开发文档/8、部署/宝塔-Docker首次配置指南.md)
|
||||
- [8、部署/宝塔反向代理说明](../开发文档/8、部署/宝塔反向代理说明.md)
|
||||
- [8、部署/提现功能完整技术文档](../开发文档/8、部署/提现功能完整技术文档.md)
|
||||
- [8、部署/订阅消息](../开发文档/8、部署/订阅消息.md)
|
||||
- [8、部署/商家转账](../开发文档/8、部署/商家转账.md)
|
||||
- [6、后端/管理端鉴权设计](../开发文档/6、后端/管理端鉴权设计.md)
|
||||
- [6、后端/miniprogram接口补全说明](../开发文档/6、后端/miniprogram接口补全说明.md)
|
||||
## 部署与运维
|
||||
|
||||
详见 [开发文档索引](../开发文档/索引.md)。
|
||||
- [8、部署/部署总览.md](../开发文档/8、部署/部署总览.md) — 部署导航(推荐)
|
||||
- [8、部署/运行与部署.md](../开发文档/8、部署/运行与部署.md)
|
||||
- [8、部署/DOCKER部署说明.md](../开发文档/8、部署/DOCKER部署说明.md)
|
||||
- [8、部署/宝塔-Docker首次配置指南.md](../开发文档/8、部署/宝塔-Docker首次配置指南.md)
|
||||
- [8、部署/自动化与Webhook.md](../开发文档/8、部署/自动化与Webhook.md)
|
||||
|
||||
## 后端规范与架构
|
||||
|
||||
- [6、后端/后端开发规范.md](../开发文档/6、后端/后端开发规范.md)
|
||||
- [6、后端/后端架构.md](../开发文档/6、后端/后端架构.md)
|
||||
|
||||
## 算法与业务规则(代码对齐)
|
||||
|
||||
- [6、后端/算法/README.md](../开发文档/6、后端/算法/README.md) — RFM、用户旅程、找伙伴匹配、存客宝留资
|
||||
|
||||
## 接口与数据库
|
||||
|
||||
- [5、接口/API接口完整文档.md](../开发文档/5、接口/API接口完整文档.md)
|
||||
- [5、接口/接口与提现.md](../开发文档/5、接口/接口与提现.md)
|
||||
- [7、数据库/数据库设计.md](../开发文档/7、数据库/数据库设计.md)
|
||||
|
||||
## 总索引
|
||||
|
||||
- [开发文档/README.md](../开发文档/README.md)
|
||||
- [开发文档/索引.md](../开发文档/索引.md)
|
||||
|
||||
4
static/mbti-avatars-16personalities-female/ENFJ.svg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
4
static/mbti-avatars-16personalities-female/ENFP.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
static/mbti-avatars-16personalities-female/ENTJ.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
4
static/mbti-avatars-16personalities-female/ENTP.svg
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
4
static/mbti-avatars-16personalities-female/ESFJ.svg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
4
static/mbti-avatars-16personalities-female/ESFP.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
4
static/mbti-avatars-16personalities-female/ESTJ.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
4
static/mbti-avatars-16personalities-female/ESTP.svg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
4
static/mbti-avatars-16personalities-female/INFJ.svg
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
4
static/mbti-avatars-16personalities-female/INFP.svg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
4
static/mbti-avatars-16personalities-female/INTJ.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
4
static/mbti-avatars-16personalities-female/INTP.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
4
static/mbti-avatars-16personalities-female/ISFJ.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
4
static/mbti-avatars-16personalities-female/ISFP.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
4
static/mbti-avatars-16personalities-female/ISTJ.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
4
static/mbti-avatars-16personalities-female/ISTP.svg
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
6
static/mbti-avatars-16personalities-female/README.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
16Personalities 彩色小人 SVG(官方静态站)
|
||||
|
||||
版权归属:16personalities.com / NERIS Analytics Limited。
|
||||
请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。
|
||||
|
||||
文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。
|
||||
4
static/mbti-avatars-16personalities-male/ENFJ.svg
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
4
static/mbti-avatars-16personalities-male/ENFP.svg
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
4
static/mbti-avatars-16personalities-male/ENTJ.svg
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
4
static/mbti-avatars-16personalities-male/ENTP.svg
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
4
static/mbti-avatars-16personalities-male/ESFJ.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
4
static/mbti-avatars-16personalities-male/ESFP.svg
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
4
static/mbti-avatars-16personalities-male/ESTJ.svg
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
4
static/mbti-avatars-16personalities-male/ESTP.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
4
static/mbti-avatars-16personalities-male/INFJ.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
4
static/mbti-avatars-16personalities-male/INFP.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
static/mbti-avatars-16personalities-male/INTJ.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
4
static/mbti-avatars-16personalities-male/INTP.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
4
static/mbti-avatars-16personalities-male/ISFJ.svg
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
4
static/mbti-avatars-16personalities-male/ISFP.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
4
static/mbti-avatars-16personalities-male/ISTJ.svg
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
4
static/mbti-avatars-16personalities-male/ISTP.svg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
6
static/mbti-avatars-16personalities-male/README.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
16Personalities 彩色小人 SVG(官方静态站)
|
||||
|
||||
版权归属:16personalities.com / NERIS Analytics Limited。
|
||||
请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。
|
||||
|
||||
文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。
|
||||
BIN
static/mbti-avatars-png-female/ENFJ.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/mbti-avatars-png-female/ENFP.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/mbti-avatars-png-female/ENTJ.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/mbti-avatars-png-female/ENTP.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
static/mbti-avatars-png-female/ESFJ.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
static/mbti-avatars-png-female/ESFP.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/mbti-avatars-png-female/ESTJ.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/mbti-avatars-png-female/ESTP.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/mbti-avatars-png-female/INFJ.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/mbti-avatars-png-female/INFP.png
Normal file
|
After Width: | Height: | Size: 21 KiB |