更新首页逻辑以支持动态标题生成,优化用户体验。调整管理后台资源文件,替换旧的 JavaScript 和 CSS 文件,提升页面性能和样式一致性。同时,更新数据库结构以支持更细粒度的推送状态。
This commit is contained in:
@@ -321,8 +321,11 @@ Page({
|
||||
|
||||
_applyHomeMpUi() {
|
||||
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
|
||||
const baseTitle = String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对'
|
||||
const prefix = String(h.pinnedTitlePrefix != null ? h.pinnedTitlePrefix : '派对会员').trim()
|
||||
const tpl = String(h.pinnedMainTitleTemplate || '').trim()
|
||||
const patch = {
|
||||
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
|
||||
mpUiLogoTitle: baseTitle,
|
||||
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
|
||||
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
|
||||
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
|
||||
@@ -335,13 +338,29 @@ Page({
|
||||
if (pinned && pinned.token) {
|
||||
const displayAv =
|
||||
pinned.avatar && isSafeImageSrc(pinned.avatar) ? pinned.avatar : DEFAULT_KARUO_LINK_AVATAR
|
||||
patch.mpUiLinkKaruoText = `点击链接${pinned.name || '好友'}`
|
||||
const nm = pinned.name || '好友'
|
||||
patch.mpUiLinkKaruoText = `点击链接${nm}`
|
||||
patch.mpUiLinkKaruoDisplay = displayAv
|
||||
let mainTitle = baseTitle
|
||||
if (tpl) {
|
||||
mainTitle = tpl
|
||||
.replace(/\{\{name\}\}/g, nm)
|
||||
.replace(/\{\{prefix\}\}/g, prefix)
|
||||
.trim() || baseTitle
|
||||
} else if (prefix) {
|
||||
mainTitle = `${prefix} · ${nm}`
|
||||
} else {
|
||||
mainTitle = `@${nm}`
|
||||
}
|
||||
patch.mpUiLogoTitle = mainTitle
|
||||
} else {
|
||||
patch.mpUiLinkKaruoText = ''
|
||||
patch.mpUiLinkKaruoDisplay = DEFAULT_KARUO_LINK_AVATAR
|
||||
}
|
||||
this.setData(patch)
|
||||
try {
|
||||
wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' })
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
/** 拉取后台置顶 @人物,合并到首页右上角「链接」区 */
|
||||
|
||||
@@ -30,6 +30,28 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
|
||||
},
|
||||
|
||||
/**
|
||||
* 未登录解锁前:先展示后台可配的「链接 vs 解锁」说明,用户确认后再弹出登录引导(mpUi.memberDetailPage)
|
||||
*/
|
||||
_showUnlockIntroThenLogin(afterConfirm) {
|
||||
const mp = app.globalData.configCache?.mpConfig?.mpUi?.memberDetailPage || {}
|
||||
const title = String(mp.unlockIntroTitle || '解锁与链接说明').trim() || '解锁与链接说明'
|
||||
const body = String(
|
||||
mp.unlockIntroBody ||
|
||||
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。',
|
||||
).trim()
|
||||
wx.showModal({
|
||||
title,
|
||||
content: body,
|
||||
confirmText: '我知道了',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (!r.confirm) return
|
||||
if (typeof afterConfirm === 'function') afterConfirm()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async loadMember(id) {
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
@@ -377,14 +399,16 @@ Page({
|
||||
if (field === 'wechat' && member.wechatUnlocked) return true
|
||||
if (field === 'contact' && member.contactUnlocked) return true
|
||||
if (!app.globalData.isLoggedIn) {
|
||||
wx.showModal({
|
||||
title: '需要登录',
|
||||
content: field === 'wechat'
|
||||
? '登录后可解锁并复制对方微信号,再按步骤去微信添加好友。'
|
||||
: '登录后可解锁并复制对方手机号,便于添加好友或回拨。',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
this._showUnlockIntroThenLogin(() => {
|
||||
wx.showModal({
|
||||
title: '需要登录',
|
||||
content: field === 'wechat'
|
||||
? '登录后可解锁并复制对方微信号,再按步骤去微信添加好友。'
|
||||
: '登录后可解锁并复制对方手机号,便于添加好友或回拨。',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) },
|
||||
})
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -445,12 +469,14 @@ Page({
|
||||
if (!member?.id || (field !== 'contact' && field !== 'wechat')) return
|
||||
const isLoggedIn = app.globalData.isLoggedIn
|
||||
if (!isLoggedIn) {
|
||||
wx.showModal({
|
||||
title: '需要登录',
|
||||
content: '请先登录后再解锁超级个体联系方式',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
this._showUnlockIntroThenLogin(() => {
|
||||
wx.showModal({
|
||||
title: '需要登录',
|
||||
content: '请先登录后再解锁超级个体联系方式',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) },
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,4 +115,11 @@ Page({
|
||||
goBack() {
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
/** 与超级个体同款名片:后台为导师绑定 userId 后展示 */
|
||||
goMemberCard() {
|
||||
const id = this.data.mentor && this.data.mentor.userId
|
||||
if (!id) return
|
||||
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(String(id))}` })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<text class="mentor-name">{{mentor.name}}</text>
|
||||
<text class="mentor-intro">{{mentor.intro}}</text>
|
||||
<view class="mentor-quote" wx:if="{{mentor.quote}}">{{mentor.quote}}</view>
|
||||
<view class="mentor-card-link" wx:if="{{mentor.userId}}" bindtap="goMemberCard">
|
||||
<text class="mentor-card-link-text">查看派对会员名片(与超级个体同页)</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(0,206,209,0.85)" customClass="mentor-card-link-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{mentor.whyFind}}">
|
||||
|
||||
@@ -15,6 +15,22 @@
|
||||
.mentor-intro { font-size: 28rpx; color: #4FD1C5; font-weight: 500; margin-bottom: 24rpx; }
|
||||
.mentor-quote { padding: 32rpx; background: #1E1E1E; border-left: 8rpx solid #4FD1C5; border-radius: 24rpx; font-size: 28rpx; color: #d4d4d8; text-align: left; width: 100%; max-width: 600rpx; box-sizing: border-box; }
|
||||
|
||||
.mentor-card-link {
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx 28rpx;
|
||||
background: rgba(79, 209, 197, 0.08);
|
||||
border: 1rpx solid rgba(79, 209, 197, 0.35);
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mentor-card-link-text { font-size: 26rpx; color: #4FD1C5; flex: 1; padding-right: 16rpx; text-align: left; }
|
||||
.mentor-card-link-arrow { flex-shrink: 0; }
|
||||
|
||||
.block { padding: 0 24rpx 64rpx; }
|
||||
.block-header { display: flex; align-items: center; margin-bottom: 24rpx; }
|
||||
.block-num { background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: bold; padding: 8rpx 20rpx; border-radius: 999rpx; margin-right: 16rpx; }
|
||||
|
||||
@@ -614,6 +614,16 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
},
|
||||
|
||||
/** 资料卡右侧齿轮:进入资料编辑(完整一页编辑,与分步向导区分) */
|
||||
goToProfileEdit() {
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
trackClick('my', 'btn_click', '资料编辑')
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1&wizard=0' })
|
||||
},
|
||||
|
||||
async onChooseAvatar(e) {
|
||||
const tempAvatarUrl = e.detail?.avatarUrl
|
||||
if (!tempAvatarUrl) return
|
||||
|
||||
@@ -28,13 +28,18 @@
|
||||
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
|
||||
<view class="vip-badge vip-badge-gray" wx:else>VIP</view>
|
||||
</view>
|
||||
<view class="profile-meta">
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<view class="profile-meta-row">
|
||||
<view class="profile-meta">
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
</view>
|
||||
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
|
||||
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
|
||||
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
|
||||
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
|
||||
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
|
||||
<view class="profile-settings-hit" catchtap="goToProfileEdit" hover-class="profile-settings-hit-active" aria-label="编辑资料">
|
||||
<icon name="setting" size="44" color="#4FD1C5" customClass="profile-settings-icon"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -72,7 +72,25 @@
|
||||
padding: 4rpx 12rpx; border-radius: 8rpx;
|
||||
}
|
||||
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
|
||||
/* 昵称区 + 右侧设置:齿轮与名片/会员按钮整体垂直居中 */
|
||||
.profile-meta-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.profile-settings-hit {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 8rpx 12rpx 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.profile-settings-hit-active { opacity: 0.72; }
|
||||
.profile-settings-icon { display: block; opacity: 0.92; }
|
||||
.profile-name-row { display: flex; align-items: center; justify-content: flex-start; gap: 16rpx; flex-wrap: wrap; }
|
||||
.user-name {
|
||||
font-size: 44rpx; font-weight: bold; color: #fff;
|
||||
|
||||
@@ -127,6 +127,21 @@ Page({
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
/** 分步向导时:齿轮 → 一页完整编辑(与「派对会员」资料同路径) */
|
||||
onOpenFullEdit() {
|
||||
if (!this.data.wizardMode) return
|
||||
wx.showModal({
|
||||
title: '切换到完整编辑',
|
||||
content: '将在一页中展示全部资料项,便于一次性填写。当前分步进度不会丢失,可随时返回。',
|
||||
confirmText: '进入完整编辑',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (!r.confirm) return
|
||||
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?full=1&wizard=0' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否走三步向导:资料未「手机号+昵称」齐全且未标记完成,且非 full=1、非 VIP 开通页强制单页。
|
||||
* 老用户已齐全则自动写 DONE,避免重复向导。
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">{{wizardMode ? '分步档案(' + wizardStep + '/3)' : '编辑资料'}}</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
<view class="nav-right">
|
||||
<view wx:if="{{wizardMode}}" class="nav-gear" bindtap="onOpenFullEdit" hover-class="nav-gear-hover" aria-label="切换完整编辑">
|
||||
<icon name="setting" size="40" color="#5EEAD4" customClass="gear-icon"></icon>
|
||||
</view>
|
||||
<view wx:else class="nav-right-spacer"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
.nav-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 44px; padding: 0 24rpx;
|
||||
height: 44px; padding: 0 16rpx 0 8rpx;
|
||||
background: rgba(5,11,20,0.9); backdrop-filter: blur(8rpx);
|
||||
border-bottom: 1rpx solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.nav-back { width: 60rpx; padding: 16rpx 0; }
|
||||
.nav-back { width: 56rpx; padding: 16rpx 8rpx; flex-shrink: 0; }
|
||||
.back-icon { font-size: 44rpx; color: #5EEAD4; }
|
||||
.nav-title { font-size: 36rpx; font-weight: 600; }
|
||||
.nav-placeholder { width: 60rpx; }
|
||||
.nav-title {
|
||||
flex: 1; min-width: 0; font-size: 32rpx; font-weight: 600;
|
||||
text-align: center; padding: 0 8rpx;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.nav-right { width: 56rpx; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; }
|
||||
.nav-right-spacer { width: 100%; height: 1px; }
|
||||
.nav-gear { padding: 12rpx 8rpx; }
|
||||
.nav-gear-hover { opacity: 0.75; }
|
||||
.gear-icon { display: block; }
|
||||
|
||||
.loading { padding: 96rpx; text-align: center; color: #94A3B8; }
|
||||
|
||||
|
||||
@@ -66,6 +66,42 @@ function normalizeMentionSegments(segments) {
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLinkTagLabel(raw) {
|
||||
return String(raw || '')
|
||||
.replace(/^[##\s\u00a0\u200b\u3000]+/u, '')
|
||||
.replace(/[\s\u00a0\u200b\u3000]+$/u, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function resolveLinkTagByLabel(label) {
|
||||
const normalized = normalizeLinkTagLabel(label)
|
||||
if (!normalized) return null
|
||||
const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
|
||||
for (const t of tags) {
|
||||
if (!t) continue
|
||||
const candidates = [t.label]
|
||||
if (typeof t.aliases === 'string' && t.aliases.trim()) {
|
||||
candidates.push(...t.aliases.split(','))
|
||||
}
|
||||
for (const c of candidates) {
|
||||
if (normalizeLinkTagLabel(c) === normalized) return t
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickLinkTagField(tag, keys, defaultValue = '') {
|
||||
if (!tag || typeof tag !== 'object') return defaultValue
|
||||
for (const key of keys) {
|
||||
const v = tag[key]
|
||||
if (v == null) continue
|
||||
const s = String(v).trim()
|
||||
if (s) return s
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
// 系统信息
|
||||
@@ -100,6 +136,11 @@ Page({
|
||||
readingProgress: 0,
|
||||
/** 未解锁付费墙:合并 data.previewPercent(章节)与顶层 previewPercent(全局) */
|
||||
previewPercent: 20,
|
||||
/** mpUi.readPage.beforeLoginHint:未登录付费墙上方说明文案 */
|
||||
readBeforeLoginHint: '',
|
||||
/** 朋友圈单页:标题与说明(mpUi.readPage.singlePageTitle / singlePagePaywallHint) */
|
||||
readSinglePageTitle: '解锁全文',
|
||||
readSinglePageHint: '',
|
||||
showPaywall: false,
|
||||
|
||||
// 上一篇/下一篇
|
||||
@@ -209,7 +250,18 @@ Page({
|
||||
const mp = (cfg && cfg.mpConfig) || {}
|
||||
const auditMode = !!mp.auditMode
|
||||
app.globalData.auditMode = auditMode
|
||||
if (typeof this.setData === 'function') this.setData({ auditMode })
|
||||
const rp = (mp.mpUi && mp.mpUi.readPage) || {}
|
||||
const readBeforeLoginHint = String(rp.beforeLoginHint || '').trim()
|
||||
const readSinglePageTitle = String(rp.singlePageTitle || '解锁全文').trim() || '解锁全文'
|
||||
const readSinglePageHint = String(rp.singlePagePaywallHint || '').trim()
|
||||
if (typeof this.setData === 'function') {
|
||||
this.setData({
|
||||
auditMode,
|
||||
readBeforeLoginHint,
|
||||
readSinglePageTitle,
|
||||
readSinglePageHint,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (extras && Array.isArray(extras.linkTags)) {
|
||||
app.globalData.linkTagsConfig = extras.linkTags
|
||||
@@ -660,18 +712,18 @@ Page({
|
||||
onLinkTagTap(e) {
|
||||
let url = (e.currentTarget.dataset.url || '').trim()
|
||||
const label = (e.currentTarget.dataset.label || '').trim()
|
||||
let tagType = (e.currentTarget.dataset.tagType || '').trim()
|
||||
let tagType = (e.currentTarget.dataset.tagType || '').trim().toLowerCase()
|
||||
let pagePath = (e.currentTarget.dataset.pagePath || '').trim()
|
||||
let mpKey = (e.currentTarget.dataset.mpKey || '').trim()
|
||||
|
||||
// 旧格式(<a href>)tagType 为空 → 按 label 从缓存 linkTags 补充类型信息
|
||||
if (!tagType && label) {
|
||||
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
|
||||
const cached = resolveLinkTagByLabel(label)
|
||||
if (cached) {
|
||||
tagType = cached.type || 'url'
|
||||
pagePath = cached.pagePath || ''
|
||||
if (!url) url = cached.url || ''
|
||||
if (cached.mpKey) mpKey = cached.mpKey
|
||||
tagType = pickLinkTagField(cached, ['type', 'tagType'], 'url').toLowerCase()
|
||||
pagePath = pickLinkTagField(cached, ['pagePath', 'page_path'], '')
|
||||
if (!url) url = pickLinkTagField(cached, ['url', 'linkUrl', 'link_url'], '')
|
||||
if (!mpKey) mpKey = pickLinkTagField(cached, ['mpKey', 'mp_key', 'appId', 'app_id'], '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,8 +737,8 @@ Page({
|
||||
// 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
|
||||
if (tagType === 'miniprogram') {
|
||||
if (!mpKey && label) {
|
||||
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
|
||||
if (cached) mpKey = cached.mpKey || ''
|
||||
const cached = resolveLinkTagByLabel(label)
|
||||
if (cached) mpKey = pickLinkTagField(cached, ['mpKey', 'mp_key', 'appId', 'app_id'], '')
|
||||
}
|
||||
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
|
||||
if (linked && linked.appId) {
|
||||
|
||||
@@ -127,7 +127,8 @@
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
|
||||
<block wx:if="{{readSinglePageMode}}">
|
||||
<text class="paywall-title">解锁全文</text>
|
||||
<text class="paywall-title">{{readSinglePageTitle}}</text>
|
||||
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
@@ -135,11 +136,12 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
|
||||
<text class="paywall-desc">已阅读{{previewPercent}}%,登录并支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 后阅读全文</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
@@ -199,7 +201,8 @@
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
|
||||
<block wx:if="{{readSinglePageMode}}">
|
||||
<text class="paywall-title">解锁全文</text>
|
||||
<text class="paywall-title">{{readSinglePageTitle}}</text>
|
||||
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
@@ -207,7 +210,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
|
||||
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
Normal file
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-CjWhuGfZ.css
vendored
1
soul-admin/dist/assets/index-CjWhuGfZ.css
vendored
File diff suppressed because one or more lines are too long
1010
soul-admin/dist/assets/index-DJrZIu_L.js
vendored
1010
soul-admin/dist/assets/index-DJrZIu_L.js
vendored
File diff suppressed because one or more lines are too long
1010
soul-admin/dist/assets/index-i0PBc3Gp.js
vendored
Normal file
1010
soul-admin/dist/assets/index-i0PBc3Gp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-DJrZIu_L.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CjWhuGfZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-i0PBc3Gp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHhAT-JW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -24,6 +24,8 @@ const primaryMenuItems = [
|
||||
|
||||
export function AdminLayout() {
|
||||
const location = useLocation()
|
||||
const pathnameRef = useRef(location.pathname)
|
||||
pathnameRef.current = location.pathname
|
||||
const navigate = useNavigate()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
@@ -32,11 +34,10 @@ export function AdminLayout() {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 仅在布局首次就绪时校验一次;路由切换不再重置 authChecked,避免侧栏点菜单时全屏「加载中」
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
setAuthChecked(false)
|
||||
let cancelled = false
|
||||
// 鉴权优化:先检查 token,无 token 直接跳登录,避免无效请求
|
||||
if (!getAdminToken()) {
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
@@ -48,19 +49,19 @@ export function AdminLayout() {
|
||||
setAuthChecked(true)
|
||||
} else {
|
||||
clearAdminToken()
|
||||
navigate('/login', { replace: true, state: { from: location.pathname } })
|
||||
navigate('/login', { replace: true, state: { from: pathnameRef.current } })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
clearAdminToken()
|
||||
navigate('/login', { replace: true, state: { from: location.pathname } })
|
||||
navigate('/login', { replace: true, state: { from: pathnameRef.current } })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [location.pathname, mounted, navigate])
|
||||
}, [mounted, navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
clearAdminToken()
|
||||
@@ -138,7 +139,7 @@ export function AdminLayout() {
|
||||
|
||||
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0 flex flex-col">
|
||||
<RechargeAlert />
|
||||
<div className="w-full min-w-[1024px] min-h-full flex-1">
|
||||
<div className="w-full min-w-0 min-h-full flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -603,11 +603,11 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-nowrap gap-6 mb-8 overflow-x-auto pb-1">
|
||||
<div className="grid gap-6 mb-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="min-w-[220px] flex-1 bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||||
className="min-w-0 bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||||
onClick={() => stat.link && navigate(stat.link)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom'
|
||||
import {
|
||||
Users,
|
||||
TrendingUp,
|
||||
@@ -18,8 +17,6 @@ import {
|
||||
Undo2,
|
||||
Settings,
|
||||
Zap,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
@@ -35,7 +32,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
interface TodayClicksByPageItem {
|
||||
page: string
|
||||
@@ -131,8 +128,9 @@ interface Order {
|
||||
|
||||
export function DistributionPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings' | 'leads'
|
||||
'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'
|
||||
>('overview')
|
||||
/** 订单 Tab 内:普通订单 / 代付请求 */
|
||||
const [orderSubView, setOrderSubView] = useState<'orders' | 'giftpay'>('orders')
|
||||
@@ -179,234 +177,12 @@ export function DistributionPage() {
|
||||
const [giftPayTotal, setGiftPayTotal] = useState(0)
|
||||
const [giftPayStatusFilter, setGiftPayStatusFilter] = useState('')
|
||||
|
||||
/** 推广中心 · 获客情况(ckb_lead_records + 存客宝推送回执) */
|
||||
type CkbContactLeadRow = {
|
||||
id: number
|
||||
userId?: string
|
||||
userNickname?: string
|
||||
userAvatar?: string
|
||||
phone?: string
|
||||
wechatId?: string
|
||||
name?: string
|
||||
source?: string
|
||||
personName?: string
|
||||
ckbPlanId?: number
|
||||
pushStatus?: string
|
||||
retryCount?: number
|
||||
ckbError?: string
|
||||
lastPushAt?: string
|
||||
nextRetryAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
const [ckbLeadRecords, setCkbLeadRecords] = useState<CkbContactLeadRow[]>([])
|
||||
const [ckbLeadTotal, setCkbLeadTotal] = useState(0)
|
||||
const [ckbLeadPage, setCkbLeadPage] = useState(1)
|
||||
const [ckbLeadPageSize, setCkbLeadPageSize] = useState(10)
|
||||
const [ckbLeadLoading, setCkbLeadLoading] = useState(false)
|
||||
const [ckbLeadError, setCkbLeadError] = useState<string | null>(null)
|
||||
const [ckbLeadSearchTerm, setCkbLeadSearchTerm] = useState('')
|
||||
const debouncedCkbLeadSearch = useDebounce(ckbLeadSearchTerm, 300)
|
||||
const [ckbLeadSourceFilter, setCkbLeadSourceFilter] = useState('')
|
||||
const [ckbLeadPushFilter, setCkbLeadPushFilter] = useState('')
|
||||
const [ckbLeadStats, setCkbLeadStats] = useState<{
|
||||
uniqueUsers?: number
|
||||
sourceStats?: { source: string; cnt: number }[]
|
||||
}>({})
|
||||
const [ckbRetryingLeadId, setCkbRetryingLeadId] = useState<number | null>(null)
|
||||
const [ckbDeletingLeadId, setCkbDeletingLeadId] = useState<number | null>(null)
|
||||
const [ckbLeadSelectedIds, setCkbLeadSelectedIds] = useState<number[]>([])
|
||||
const [ckbBatchDeleting, setCkbBatchDeleting] = useState(false)
|
||||
const ckbLeadHeaderCheckboxRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadCkbLeads = useCallback(async () => {
|
||||
setCkbLeadLoading(true)
|
||||
setCkbLeadError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
mode: 'contact',
|
||||
page: String(ckbLeadPage),
|
||||
pageSize: String(ckbLeadPageSize),
|
||||
})
|
||||
if (debouncedCkbLeadSearch.trim()) params.set('search', debouncedCkbLeadSearch.trim())
|
||||
if (ckbLeadSourceFilter) params.set('source', ckbLeadSourceFilter)
|
||||
if (ckbLeadPushFilter) params.set('pushStatus', ckbLeadPushFilter)
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
records?: CkbContactLeadRow[]
|
||||
total?: number
|
||||
stats?: { uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }
|
||||
error?: string
|
||||
}>(`/api/db/ckb-leads?${params}`)
|
||||
if (data?.success) {
|
||||
setCkbLeadRecords(data.records || [])
|
||||
setCkbLeadTotal(data.total ?? 0)
|
||||
if (data.stats) setCkbLeadStats(data.stats)
|
||||
} else {
|
||||
const msg = data?.error || '加载获客情况失败'
|
||||
setCkbLeadError(msg)
|
||||
toast.error(msg)
|
||||
setCkbLeadRecords([])
|
||||
setCkbLeadTotal(0)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '网络错误'
|
||||
setCkbLeadError(msg)
|
||||
toast.error('加载获客情况失败: ' + msg)
|
||||
setCkbLeadRecords([])
|
||||
setCkbLeadTotal(0)
|
||||
} finally {
|
||||
setCkbLeadLoading(false)
|
||||
}
|
||||
}, [
|
||||
ckbLeadPage,
|
||||
ckbLeadPageSize,
|
||||
debouncedCkbLeadSearch,
|
||||
ckbLeadSourceFilter,
|
||||
ckbLeadPushFilter,
|
||||
])
|
||||
|
||||
/** 旧链接「推广中心 → 获客情况」已合并至用户管理 → 获客列表 */
|
||||
useEffect(() => {
|
||||
setCkbLeadSelectedIds([])
|
||||
}, [debouncedCkbLeadSearch, ckbLeadSourceFilter, ckbLeadPushFilter])
|
||||
|
||||
useEffect(() => {
|
||||
const pageIds = ckbLeadRecords.map((r) => r.id)
|
||||
const n = pageIds.filter((id) => ckbLeadSelectedIds.includes(id)).length
|
||||
const el = ckbLeadHeaderCheckboxRef.current
|
||||
if (el) {
|
||||
el.indeterminate = n > 0 && n < pageIds.length
|
||||
if (searchParams.get('tab') === 'leads') {
|
||||
navigate('/users?tab=leads', { replace: true })
|
||||
}
|
||||
}, [ckbLeadRecords, ckbLeadSelectedIds])
|
||||
|
||||
function toggleCkbLeadSelectAllOnPage() {
|
||||
const pageIds = ckbLeadRecords.map((r) => r.id)
|
||||
const allOn = pageIds.length > 0 && pageIds.every((id) => ckbLeadSelectedIds.includes(id))
|
||||
if (allOn) {
|
||||
setCkbLeadSelectedIds((prev) => prev.filter((id) => !pageIds.includes(id)))
|
||||
} else {
|
||||
setCkbLeadSelectedIds((prev) => [...new Set([...prev, ...pageIds])])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCkbLeadOne(id: number) {
|
||||
setCkbLeadSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
async function batchDeleteCkbLeads() {
|
||||
if (ckbLeadSelectedIds.length === 0) {
|
||||
toast.info('请先勾选要删除的记录')
|
||||
return
|
||||
}
|
||||
const n = ckbLeadSelectedIds.length
|
||||
if (!confirm(`确定批量删除选中的 ${n} 条获客记录?删除后不可恢复。`)) return
|
||||
const CHUNK = 500
|
||||
setCkbBatchDeleting(true)
|
||||
try {
|
||||
let totalDeleted = 0
|
||||
for (let i = 0; i < ckbLeadSelectedIds.length; i += CHUNK) {
|
||||
const slice = ckbLeadSelectedIds.slice(i, i + CHUNK)
|
||||
const data = await post<{ success?: boolean; deleted?: number; error?: string }>(
|
||||
'/api/db/ckb-leads/delete-batch',
|
||||
{ ids: slice },
|
||||
)
|
||||
if (!data?.success) {
|
||||
toast.error(data?.error || '批量删除失败')
|
||||
return
|
||||
}
|
||||
totalDeleted += Number(data.deleted) || 0
|
||||
}
|
||||
toast.success(`已删除 ${totalDeleted} 条`)
|
||||
setCkbLeadSelectedIds([])
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '批量删除请求失败')
|
||||
} finally {
|
||||
setCkbBatchDeleting(false)
|
||||
void loadCkbLeads()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeCkbLeadRowAfterRetry(
|
||||
row: CkbContactLeadRow,
|
||||
rec: {
|
||||
pushStatus?: string
|
||||
retryCount?: number
|
||||
ckbError?: string
|
||||
lastPushAt?: string | null
|
||||
nextRetryAt?: string | null
|
||||
},
|
||||
): CkbContactLeadRow {
|
||||
return {
|
||||
...row,
|
||||
...(rec.pushStatus !== undefined ? { pushStatus: rec.pushStatus } : {}),
|
||||
...(typeof rec.retryCount === 'number' ? { retryCount: rec.retryCount } : {}),
|
||||
...(rec.ckbError !== undefined ? { ckbError: rec.ckbError } : {}),
|
||||
...(rec.lastPushAt !== undefined ? { lastPushAt: rec.lastPushAt ?? undefined } : {}),
|
||||
...(rec.nextRetryAt !== undefined ? { nextRetryAt: rec.nextRetryAt ?? undefined } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function retryCkbLeadPush(recordId: number) {
|
||||
if (!recordId) return
|
||||
setCkbRetryingLeadId(recordId)
|
||||
try {
|
||||
const data = await post<{
|
||||
success?: boolean
|
||||
pushed?: boolean
|
||||
error?: string
|
||||
record?: {
|
||||
pushStatus?: string
|
||||
retryCount?: number
|
||||
ckbError?: string
|
||||
lastPushAt?: string | null
|
||||
nextRetryAt?: string | null
|
||||
}
|
||||
}>('/api/db/ckb-leads/retry', {
|
||||
id: recordId,
|
||||
})
|
||||
if (data?.success) {
|
||||
toast.success(data.pushed ? '重推成功' : '已发起重推,请刷新查看状态')
|
||||
if (data.record) {
|
||||
setCkbLeadRecords((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === recordId ? mergeCkbLeadRowAfterRetry(row, data.record!) : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
toast.error(data?.error || '重推失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '重推请求失败')
|
||||
} finally {
|
||||
setCkbRetryingLeadId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCkbLeadRecord(recordId: number) {
|
||||
if (!recordId) return
|
||||
if (!confirm('确定删除该条获客记录?删除后不可恢复,用户可再次提交留资。')) return
|
||||
setCkbDeletingLeadId(recordId)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/ckb-leads/delete', { id: recordId })
|
||||
if (data?.success) {
|
||||
toast.success('已删除')
|
||||
} else {
|
||||
toast.error(data?.error || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除请求失败')
|
||||
} finally {
|
||||
setCkbDeletingLeadId(null)
|
||||
void loadCkbLeads()
|
||||
}
|
||||
}
|
||||
|
||||
function pushStatusBadge(status?: string) {
|
||||
if (status === 'success')
|
||||
return <Badge className="bg-emerald-500/20 text-emerald-300 border-0 text-xs">成功</Badge>
|
||||
if (status === 'failed') return <Badge className="bg-red-500/20 text-red-300 border-0 text-xs">失败</Badge>
|
||||
return <Badge className="bg-amber-500/20 text-amber-300 border-0 text-xs">待推送</Badge>
|
||||
}
|
||||
}, [searchParams, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData()
|
||||
@@ -419,8 +195,7 @@ export function DistributionPage() {
|
||||
t === 'orders' ||
|
||||
t === 'bindings' ||
|
||||
t === 'withdrawals' ||
|
||||
t === 'settings' ||
|
||||
t === 'leads'
|
||||
t === 'settings'
|
||||
) {
|
||||
setActiveTab(t)
|
||||
}
|
||||
@@ -431,10 +206,6 @@ export function DistributionPage() {
|
||||
}, [activeTab, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'leads') {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
loadTabData(activeTab)
|
||||
}, [activeTab])
|
||||
|
||||
@@ -446,9 +217,6 @@ export function DistributionPage() {
|
||||
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
|
||||
void loadTabData(activeTab, true)
|
||||
}
|
||||
if (activeTab === 'leads') {
|
||||
void loadCkbLeads()
|
||||
}
|
||||
}, [
|
||||
page,
|
||||
pageSize,
|
||||
@@ -458,9 +226,6 @@ export function DistributionPage() {
|
||||
orderSubView,
|
||||
giftPayPage,
|
||||
giftPayStatusFilter,
|
||||
ckbLeadPage,
|
||||
ckbLeadPageSize,
|
||||
loadCkbLeads,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -612,8 +377,6 @@ export function DistributionPage() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'leads':
|
||||
break
|
||||
}
|
||||
setLoadedTabs((prev) => new Set(prev).add(tab))
|
||||
} catch (e) {
|
||||
@@ -625,10 +388,6 @@ export function DistributionPage() {
|
||||
|
||||
async function refreshCurrentTab() {
|
||||
setError(null)
|
||||
if (activeTab === 'leads') {
|
||||
await loadCkbLeads()
|
||||
return
|
||||
}
|
||||
setLoadedTabs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(activeTab)
|
||||
@@ -791,7 +550,6 @@ export function DistributionPage() {
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
const ckbLeadTotalPages = Math.ceil(ckbLeadTotal / ckbLeadPageSize) || 1
|
||||
const displayOrders = orders
|
||||
const displayBindings = bindings.filter((b) => {
|
||||
if (!searchTerm) return true
|
||||
@@ -828,14 +586,12 @@ export function DistributionPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={refreshCurrentTab}
|
||||
disabled={loading || (activeTab === 'leads' && ckbLeadLoading)}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 mr-1.5 ${loading || (activeTab === 'leads' && ckbLeadLoading) ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
@@ -847,7 +603,6 @@ export function DistributionPage() {
|
||||
{ key: 'bindings', label: '绑定管理', icon: Link2 },
|
||||
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
|
||||
{ key: 'settings', label: '推广设置', icon: Settings },
|
||||
{ key: 'leads', label: '获客情况', icon: UserPlus },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -857,9 +612,6 @@ export function DistributionPage() {
|
||||
setStatusFilter('all')
|
||||
setSearchTerm('')
|
||||
if (tab.key !== 'orders') setOrderSubView('orders')
|
||||
if (tab.key === 'leads') {
|
||||
setCkbLeadPage(1)
|
||||
}
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === tab.key
|
||||
@@ -970,6 +722,27 @@ export function DistributionPage() {
|
||||
</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">
|
||||
@@ -1591,305 +1364,6 @@ export function DistributionPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'leads' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-500 text-sm">
|
||||
展示用户通过文章 @、首页等入口留下的线索,以及转发存客宝接口的推送状态与失败原因(数据来自{' '}
|
||||
<code className="text-gray-400">ckb_lead_records</code>)。
|
||||
</p>
|
||||
{ckbLeadError && (
|
||||
<div className="px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{ckbLeadError}</span>
|
||||
<button type="button" className="shrink-0 ml-2 hover:text-red-300" onClick={() => setCkbLeadError(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-3 bg-[#0f2137] border border-gray-700/50 rounded-lg">
|
||||
<p className="text-gray-500 text-xs">总留资条数</p>
|
||||
<p className="text-xl font-bold text-white">{ckbLeadTotal}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0f2137] border border-gray-700/50 rounded-lg">
|
||||
<p className="text-gray-500 text-xs">去重用户数</p>
|
||||
<p className="text-xl font-bold text-[#38bdac]">{ckbLeadStats.uniqueUsers ?? 0}</p>
|
||||
</div>
|
||||
{(ckbLeadStats.sourceStats || []).slice(0, 2).map((s) => (
|
||||
<div key={s.source} className="p-3 bg-[#0f2137] border border-gray-700/50 rounded-lg">
|
||||
<p className="text-gray-500 text-xs">来源:{s.source}</p>
|
||||
<p className="text-xl font-bold text-purple-400">{s.cnt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={ckbLeadSearchTerm}
|
||||
onChange={(e) => {
|
||||
setCkbLeadSearchTerm(e.target.value)
|
||||
setCkbLeadPage(1)
|
||||
}}
|
||||
placeholder="搜索昵称 / 手机 / 微信 / 姓名…"
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
{ckbLeadStats.sourceStats && ckbLeadStats.sourceStats.length > 0 && (
|
||||
<select
|
||||
value={ckbLeadSourceFilter}
|
||||
onChange={(e) => {
|
||||
setCkbLeadSourceFilter(e.target.value)
|
||||
setCkbLeadPage(1)
|
||||
}}
|
||||
className="px-3 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white text-sm shrink-0"
|
||||
>
|
||||
<option value="">全部来源</option>
|
||||
{ckbLeadStats.sourceStats.map((s) => (
|
||||
<option key={s.source} value={s.source}>
|
||||
{s.source}({s.cnt})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={ckbLeadPushFilter}
|
||||
onChange={(e) => {
|
||||
setCkbLeadPushFilter(e.target.value)
|
||||
setCkbLeadPage(1)
|
||||
}}
|
||||
className="px-3 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white text-sm shrink-0"
|
||||
>
|
||||
<option value="">推送状态:全部</option>
|
||||
<option value="pending">待推送</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadCkbLeads()}
|
||||
disabled={ckbLeadLoading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${ckbLeadLoading ? 'animate-spin' : ''}`} />
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
{ckbLeadRecords.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3 px-1 py-2 rounded-lg bg-[#0a1628]/80 border border-gray-700/40">
|
||||
<span className="text-sm text-gray-400">
|
||||
已选 <span className="text-[#38bdac] font-medium">{ckbLeadSelectedIds.length}</span> 条(可翻页继续勾选;改搜索或来源/状态筛选会清空)
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={ckbBatchDeleting || ckbLeadSelectedIds.length === 0}
|
||||
onClick={() => void batchDeleteCkbLeads()}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/15 bg-transparent"
|
||||
>
|
||||
<Trash2 className={`w-3.5 h-3.5 mr-1.5 ${ckbBatchDeleting ? 'animate-pulse' : ''}`} />
|
||||
{ckbBatchDeleting ? '删除中…' : '批量删除选中'}
|
||||
</Button>
|
||||
{ckbLeadSelectedIds.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
onClick={() => setCkbLeadSelectedIds([])}
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{ckbLeadRecords.length === 0 && !ckbLeadLoading ? (
|
||||
<div className="py-16 text-center text-gray-500">
|
||||
<UserPlus className="w-12 h-12 mx-auto mb-3 text-[#38bdac]/30" />
|
||||
<p>暂无记录,或当前筛选无匹配</p>
|
||||
</div>
|
||||
) : ckbLeadRecords.length === 0 && ckbLeadLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载获客情况…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-h-[200px]">
|
||||
<div
|
||||
className={
|
||||
ckbLeadLoading ? 'pointer-events-none select-none opacity-50' : ''
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-3 w-10 text-center font-medium">
|
||||
<input
|
||||
ref={ckbLeadHeaderCheckboxRef}
|
||||
type="checkbox"
|
||||
checked={
|
||||
ckbLeadRecords.length > 0 &&
|
||||
ckbLeadRecords.every((r) => ckbLeadSelectedIds.includes(r.id))
|
||||
}
|
||||
onChange={toggleCkbLeadSelectAllOnPage}
|
||||
disabled={ckbLeadLoading}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer"
|
||||
title="全选本页"
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 text-left font-medium">点击用户</th>
|
||||
<th className="p-4 text-left font-medium">@ 目标人物</th>
|
||||
<th className="p-4 text-left font-medium">联系方式</th>
|
||||
<th className="p-4 text-left font-medium">来源</th>
|
||||
<th className="p-4 text-left font-medium">存客宝推送</th>
|
||||
<th className="p-4 text-left font-medium">最后推送</th>
|
||||
<th className="p-4 text-left font-medium">创建时间</th>
|
||||
<th className="p-4 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{ckbLeadRecords.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-3 w-10 text-center align-middle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ckbLeadSelectedIds.includes(r.id)}
|
||||
onChange={() => toggleCkbLeadOne(r.id)}
|
||||
disabled={ckbBatchDeleting || ckbLeadLoading}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2 min-w-[10rem]">
|
||||
{r.userAvatar ? (
|
||||
<img
|
||||
src={r.userAvatar}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-gray-600 flex items-center justify-center text-white text-sm shrink-0">
|
||||
{(r.userNickname || r.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-white font-medium">{r.userNickname || r.name || '-'}</p>
|
||||
{r.userId && (
|
||||
<p className="text-gray-500 text-xs font-mono truncate max-w-[8rem]">
|
||||
{r.userId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-[#38bdac]">{r.personName || '-'}</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
<div className="space-y-0.5">
|
||||
<p>{r.phone || '-'}</p>
|
||||
<p className="text-xs">{r.wechatId || '-'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
|
||||
{r.source || '未知'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="space-y-1 max-w-[14rem]">
|
||||
{pushStatusBadge(r.pushStatus)}
|
||||
{r.ckbError ? (
|
||||
<p className="text-xs text-red-300 break-words" title={r.ckbError}>
|
||||
{r.ckbError}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">—</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
重试 {typeof r.retryCount === 'number' ? r.retryCount : 0} 次
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-xs">
|
||||
{r.lastPushAt ? new Date(r.lastPushAt).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-xs">
|
||||
{r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
ckbLeadLoading ||
|
||||
ckbBatchDeleting ||
|
||||
ckbRetryingLeadId === r.id ||
|
||||
ckbDeletingLeadId === r.id
|
||||
}
|
||||
onClick={() => retryCkbLeadPush(r.id)}
|
||||
className="border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent h-8"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 mr-1 ${ckbRetryingLeadId === r.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
重推
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
ckbLeadLoading ||
|
||||
ckbBatchDeleting ||
|
||||
ckbDeletingLeadId === r.id ||
|
||||
ckbRetryingLeadId === r.id
|
||||
}
|
||||
onClick={() => deleteCkbLeadRecord(r.id)}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/15 bg-transparent h-8"
|
||||
>
|
||||
<Trash2
|
||||
className={`w-3.5 h-3.5 mr-1 ${ckbDeletingLeadId === r.id ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={ckbLeadPage}
|
||||
totalPages={ckbLeadTotalPages}
|
||||
total={ckbLeadTotal}
|
||||
pageSize={ckbLeadPageSize}
|
||||
onPageChange={setCkbLeadPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setCkbLeadPageSize(n)
|
||||
setCkbLeadPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{ckbLeadLoading && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-b-lg bg-[#0f2137]/85 backdrop-blur-[2px]"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
>
|
||||
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
|
||||
<span className="mt-2 text-sm text-gray-400">加载中…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, Handshake, GraduationCap, UserPlus, BarChart3, Link2, ChevronRight } from 'lucide-react'
|
||||
import { Users, Handshake, GraduationCap, UserPlus, Link2, ChevronRight } from 'lucide-react'
|
||||
import { FindPartnerTab } from './tabs/FindPartnerTab'
|
||||
import { ResourceDockingTab } from './tabs/ResourceDockingTab'
|
||||
import { MentorTab } from './tabs/MentorTab'
|
||||
import { TeamRecruitTab } from './tabs/TeamRecruitTab'
|
||||
import { CKBStatsTab } from './tabs/CKBStatsTab'
|
||||
import { CKBConfigPanel } from './tabs/CKBConfigPanel'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'stats', label: '数据统计', icon: BarChart3, desc: '匹配与获客概览' },
|
||||
{ id: 'partner', label: '找伙伴', icon: Users, desc: '匹配池与记录' },
|
||||
{ id: 'resource', label: '资源对接', icon: Handshake, desc: '人脉资源' },
|
||||
{ id: 'mentor', label: '导师预约', icon: GraduationCap, desc: '预约与管理' },
|
||||
@@ -19,9 +17,8 @@ const TABS = [
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
export function FindPartnerPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('stats')
|
||||
const [activeTab, setActiveTab] = useState<TabId>('partner')
|
||||
const [showCKBPanel, setShowCKBPanel] = useState(false)
|
||||
const [ckbPanelTab, setCkbPanelTab] = useState<'overview' | 'submitted' | 'contact' | 'config' | 'test' | 'doc'>('overview')
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full max-w-7xl mx-auto">
|
||||
@@ -31,7 +28,9 @@ export function FindPartnerPage() {
|
||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||
找伙伴
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm mt-0.5">匹配、获客、导师与团队管理</p>
|
||||
<p className="text-gray-500 text-sm mt-0.5">
|
||||
匹配、获客、导师与团队管理 · 汇总数据见「仪表盘」与「推广中心」
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -46,7 +45,7 @@ export function FindPartnerPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCKBPanel && <CKBConfigPanel initialTab={ckbPanelTab} />}
|
||||
{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) => {
|
||||
@@ -69,15 +68,6 @@ export function FindPartnerPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<CKBStatsTab
|
||||
onSwitchTab={(id) => setActiveTab(id as TabId)}
|
||||
onOpenCKB={(tab) => {
|
||||
setCkbPanelTab((tab as typeof ckbPanelTab) || 'overview')
|
||||
setShowCKBPanel(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'partner' && <FindPartnerTab />}
|
||||
{activeTab === 'resource' && <ResourceDockingTab />}
|
||||
{activeTab === 'mentor' && <MentorTab />}
|
||||
|
||||
@@ -265,8 +265,8 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
{[
|
||||
['overview', '概览'],
|
||||
['submitted', '已提交线索'],
|
||||
['contact', '有联系方式'],
|
||||
['submitted', '加入/匹配提交'],
|
||||
['contact', '留资线索(有联系方式)'],
|
||||
['config', '场景配置'],
|
||||
['test', '接口测试'],
|
||||
['doc', 'API 文档'],
|
||||
@@ -287,11 +287,11 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">已提交线索</p>
|
||||
<p className="text-gray-400 text-xs mb-2">加入/匹配提交</p>
|
||||
<p className="text-3xl font-bold text-white">{submittedLeads.length}</p>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">有联系方式(已去重)</p>
|
||||
<p className="text-gray-400 text-xs mb-2">留资线索(有联系方式,已去重)</p>
|
||||
<p className="text-3xl font-bold text-white">{contactLeadsDeduped.length}</p>
|
||||
{contactLeads.length !== contactLeadsDeduped.length && (
|
||||
<p className="text-[10px] text-gray-500 mt-1">原始 {contactLeads.length} 条</p>
|
||||
@@ -308,7 +308,7 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'submitted' && renderLeadTable(submittedLeads, '暂无已提交线索', '用户通过场景提交后会出现于此。')}
|
||||
{activeTab === 'submitted' && renderLeadTable(submittedLeads, '暂无加入/匹配提交记录', '用户在找伙伴发起加入或匹配后会出现在这里。')}
|
||||
{activeTab === 'contact' && (
|
||||
<div className="space-y-2">
|
||||
{contactLeads.length > contactLeadsDeduped.length && (
|
||||
|
||||
@@ -44,6 +44,8 @@ interface Mentor {
|
||||
judgmentStyle?: string
|
||||
sort: number
|
||||
enabled?: boolean
|
||||
/** 绑定小程序用户 id,与名片页 member-detail 一致 */
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean }) {
|
||||
@@ -66,6 +68,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
judgmentStyle: '',
|
||||
sort: 0,
|
||||
enabled: true,
|
||||
userId: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
@@ -134,6 +137,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
judgmentStyle: '',
|
||||
sort: mentors.length > 0 ? Math.max(...mentors.map((m) => m.sort)) + 1 : 0,
|
||||
enabled: true,
|
||||
userId: '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,6 +163,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
judgmentStyle: m.judgmentStyle || '',
|
||||
sort: m.sort,
|
||||
enabled: m.enabled ?? true,
|
||||
userId: m.userId || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
@@ -171,6 +176,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
setSaving(true)
|
||||
try {
|
||||
const num = (s: string) => (s === '' ? undefined : parseFloat(s))
|
||||
const uid = form.userId.trim()
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
avatar: form.avatar.trim() || undefined,
|
||||
@@ -190,6 +196,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/mentors', {
|
||||
id: editing.id,
|
||||
...payload,
|
||||
userId: uid,
|
||||
})
|
||||
if (data?.success) {
|
||||
setShowModal(false)
|
||||
@@ -198,7 +205,10 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
toast.error('更新失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', {
|
||||
...payload,
|
||||
userId: uid || undefined,
|
||||
})
|
||||
if (data?.success) {
|
||||
setShowModal(false)
|
||||
load()
|
||||
@@ -237,7 +247,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
导师管理
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
stitch_soul 导师列表,支持每个导师独立配置单次/半年/年度价格
|
||||
stitch_soul 导师列表;填写「绑定用户 ID」后,小程序导师详情可跳转「派对会员名片」(与超级个体同页)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
@@ -260,6 +270,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
<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>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
@@ -283,6 +294,19 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
<TableCell className="text-gray-400">{fmt(m.priceSingle)}</TableCell>
|
||||
<TableCell className="text-gray-400">{fmt(m.priceHalfYear)}</TableCell>
|
||||
<TableCell className="text-gray-400">{fmt(m.priceYear)}</TableCell>
|
||||
<TableCell className="text-gray-400 font-mono text-xs max-w-[120px] truncate" title={m.userId || ''}>
|
||||
{m.userId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/users?search=${encodeURIComponent(m.userId || '')}`)}
|
||||
className="text-[#38bdac] hover:underline truncate max-w-[120px] inline-block align-bottom"
|
||||
>
|
||||
{m.userId}
|
||||
</button>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{m.sort}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
@@ -306,7 +330,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
))}
|
||||
{mentors.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
|
||||
<TableCell colSpan={9} className="text-center py-12 text-gray-500">
|
||||
暂无导师,点击「新增导师」添加
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -383,6 +407,16 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">绑定用户 ID(可选,与名片页一致)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
placeholder="小程序用户 id,填后导师详情显示「查看派对会员名片」"
|
||||
value={form.userId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, userId: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">留空则仅展示导师资料;填写后 C 端可跳转 member-detail 与超级个体同款名片。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">简介</Label>
|
||||
<Input
|
||||
|
||||
@@ -133,6 +133,8 @@ const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
|
||||
logoSubtitle: '来自派对房的真实故事',
|
||||
linkKaruoText: '点击链接卡若',
|
||||
linkKaruoAvatar: '',
|
||||
pinnedTitlePrefix: '派对会员',
|
||||
pinnedMainTitleTemplate: '',
|
||||
searchPlaceholder: '搜索章节标题或内容...',
|
||||
bannerTag: '推荐',
|
||||
bannerReadMoreText: '点击阅读',
|
||||
@@ -153,6 +155,17 @@ const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
|
||||
readStatPath: '/pages/reading-records/reading-records?focus=all',
|
||||
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
|
||||
},
|
||||
memberDetailPage: {
|
||||
unlockIntroTitle: '解锁与链接说明',
|
||||
unlockIntroBody:
|
||||
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。请先阅读说明,确认后再登录。',
|
||||
},
|
||||
readPage: {
|
||||
beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
|
||||
singlePageTitle: '解锁全文',
|
||||
singlePagePaywallHint:
|
||||
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
|
||||
},
|
||||
}
|
||||
|
||||
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { normalizeImageUrl } from '@/lib/utils'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -185,11 +186,73 @@ function confirmDangerousDelete(entity: string): boolean {
|
||||
return verifyText === '删除'
|
||||
}
|
||||
|
||||
/** 获客列表:头像 + 昵称,有 userId 时可点进用户详情 */
|
||||
function LeadUserNickCell({
|
||||
userId,
|
||||
userAvatar,
|
||||
nickname,
|
||||
name,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
userId?: string
|
||||
userAvatar?: string
|
||||
nickname?: string
|
||||
name?: string
|
||||
onOpenDetail: (id: string) => void
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
const label = nickname || name || '-'
|
||||
const initial = (label === '-' ? '?' : label).charAt(0)
|
||||
const showImg = !!userAvatar?.trim() && !imgFailed
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0 max-w-[220px]">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-[#38bdac]/15 flex items-center justify-center text-xs font-medium text-[#38bdac] flex-shrink-0 overflow-hidden border border-gray-600/50"
|
||||
aria-hidden
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
src={normalizeImageUrl(userAvatar!)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-left truncate min-w-0 ${userId ? 'hover:text-[#38bdac] cursor-pointer' : 'cursor-default text-gray-300'}`}
|
||||
disabled={!userId}
|
||||
title={userId ? '查看用户详情' : undefined}
|
||||
onClick={() => {
|
||||
if (userId) onOpenDetail(userId)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function prettyJson(raw: string): string {
|
||||
const s = (raw || '').trim()
|
||||
if (!s) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2)
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
|
||||
const rawTabParam = searchParams.get('tab') || 'users'
|
||||
const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users'
|
||||
const leadActionParam = (searchParams.get('leadAction') || '').trim()
|
||||
|
||||
// ===== 用户列表 state =====
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
@@ -257,14 +320,19 @@ export function UsersPage() {
|
||||
id: number
|
||||
userId?: string
|
||||
userNickname?: string
|
||||
/** 与会员资料一致,来自 users.avatar(接口已 resolve) */
|
||||
userAvatar?: string
|
||||
phone?: string
|
||||
wechatId?: string
|
||||
name?: string
|
||||
source?: string
|
||||
planApiKey?: string
|
||||
personName?: string
|
||||
ckbPlanId?: number
|
||||
pushStatus?: 'pending' | 'success' | 'failed' | string
|
||||
retryCount?: number
|
||||
ckbCode?: number
|
||||
ckbMessage?: string
|
||||
ckbData?: string
|
||||
ckbError?: string
|
||||
lastPushAt?: string
|
||||
nextRetryAt?: string
|
||||
@@ -272,13 +340,15 @@ export function UsersPage() {
|
||||
}[]>([])
|
||||
const [leadsTotal, setLeadsTotal] = useState(0)
|
||||
const [leadsPage, setLeadsPage] = useState(1)
|
||||
const [leadsPageSize] = useState(20)
|
||||
const [leadsPageSize] = useState(10)
|
||||
const [leadsLoading, setLeadsLoading] = useState(false)
|
||||
const [leadsError, setLeadsError] = useState<string | null>(null)
|
||||
const [leadsSearchTerm, setLeadsSearchTerm] = useState('')
|
||||
const debouncedLeadsSearch = useDebounce(leadsSearchTerm, 300)
|
||||
const [leadsSourceFilter, setLeadsSourceFilter] = useState('')
|
||||
const [leadsActionFilter, setLeadsActionFilter] = useState('')
|
||||
const [leadsPushStatusFilter, setLeadsPushStatusFilter] = useState('')
|
||||
const [leadsDedupEnabled, setLeadsDedupEnabled] = useState(false)
|
||||
const [leadsStats, setLeadsStats] = useState<{ uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }>({})
|
||||
const [retryingLeadId, setRetryingLeadId] = useState<number | null>(null)
|
||||
const [deletingLeadId, setDeletingLeadId] = useState<number | null>(null)
|
||||
@@ -286,6 +356,9 @@ export function UsersPage() {
|
||||
const [batchDeletingLeads, setBatchDeletingLeads] = useState(false)
|
||||
const leadsHeaderCheckboxRef = useRef<HTMLInputElement>(null)
|
||||
const [batchRetrying, setBatchRetrying] = useState(false)
|
||||
const [showCkbDataDialog, setShowCkbDataDialog] = useState(false)
|
||||
const [ckbDataDialogTitle, setCkbDataDialogTitle] = useState('存客宝返回 data')
|
||||
const [ckbDataDialogContent, setCkbDataDialogContent] = useState('')
|
||||
const loadLeads = useCallback(async (searchVal?: string, sourceVal?: string) => {
|
||||
setLeadsLoading(true)
|
||||
setLeadsError(null)
|
||||
@@ -299,6 +372,7 @@ export function UsersPage() {
|
||||
if (s) params.set('search', s)
|
||||
const src = sourceVal ?? leadsSourceFilter
|
||||
if (src) params.set('source', src)
|
||||
if (leadsActionFilter) params.set('action', leadsActionFilter)
|
||||
if (leadsPushStatusFilter) params.set('pushStatus', leadsPushStatusFilter)
|
||||
const data = await get<{
|
||||
success?: boolean; records?: unknown[]; total?: number;
|
||||
@@ -325,15 +399,24 @@ export function UsersPage() {
|
||||
} finally {
|
||||
setLeadsLoading(false)
|
||||
}
|
||||
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter, leadsPushStatusFilter])
|
||||
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
setLeadSelectedIds([])
|
||||
}, [debouncedLeadsSearch, leadsSourceFilter, leadsPushStatusFilter])
|
||||
}, [debouncedLeadsSearch, leadsSourceFilter, leadsActionFilter, leadsPushStatusFilter])
|
||||
|
||||
// URL 同步:只在获客列表 Tab 时读取 leadAction,默认空=全部类型
|
||||
useEffect(() => {
|
||||
if (tabParam !== 'leads') return
|
||||
setLeadsActionFilter(leadActionParam)
|
||||
}, [tabParam, leadActionParam])
|
||||
|
||||
type LeadRetryRecordPatch = {
|
||||
pushStatus?: string
|
||||
retryCount?: number
|
||||
ckbCode?: number
|
||||
ckbMessage?: string
|
||||
ckbData?: string
|
||||
ckbError?: string
|
||||
lastPushAt?: string | null
|
||||
nextRetryAt?: string | null
|
||||
@@ -344,6 +427,9 @@ export function UsersPage() {
|
||||
...row,
|
||||
...(rec.pushStatus !== undefined ? { pushStatus: rec.pushStatus } : {}),
|
||||
...(typeof rec.retryCount === 'number' ? { retryCount: rec.retryCount } : {}),
|
||||
...(typeof rec.ckbCode === 'number' ? { ckbCode: rec.ckbCode } : {}),
|
||||
...(rec.ckbMessage !== undefined ? { ckbMessage: rec.ckbMessage } : {}),
|
||||
...(rec.ckbData !== undefined ? { ckbData: rec.ckbData } : {}),
|
||||
...(rec.ckbError !== undefined ? { ckbError: rec.ckbError } : {}),
|
||||
...(rec.lastPushAt !== undefined ? { lastPushAt: rec.lastPushAt ?? undefined } : {}),
|
||||
...(rec.nextRetryAt !== undefined ? { nextRetryAt: rec.nextRetryAt ?? undefined } : {}),
|
||||
@@ -436,7 +522,7 @@ export function UsersPage() {
|
||||
return
|
||||
}
|
||||
const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`
|
||||
const headers = ['ID', '昵称', '手机号', '微信号', '对应@人', '来源', '推送状态', '重试次数', '失败原因', '下次重试时间', '创建时间']
|
||||
const headers = ['ID', '昵称', '手机号', '微信号', '对应@人', '计划Key', '来源', '推送状态', '重试次数', '失败原因', '下次重试时间', '创建时间']
|
||||
const lines = [headers.join(',')]
|
||||
for (const r of failedRows) {
|
||||
lines.push([
|
||||
@@ -445,6 +531,7 @@ export function UsersPage() {
|
||||
esc(r.phone || ''),
|
||||
esc(r.wechatId || ''),
|
||||
esc(r.personName || ''),
|
||||
esc(r.planApiKey || ''),
|
||||
esc(r.source || ''),
|
||||
esc(r.pushStatus || ''),
|
||||
esc(typeof r.retryCount === 'number' ? r.retryCount : ''),
|
||||
@@ -968,7 +1055,7 @@ export function UsersPage() {
|
||||
return Math.round((filled / fields.length) * 100)
|
||||
}
|
||||
|
||||
/** 本页内去重:优先手机号,其次 userId(与小程序用户一致,常等同微信侧标识),再微信号;同键保留最新一条 */
|
||||
/** 展示列表:默认不去重(按原始记录展示);可选开启去重用于运营查看「按人合并」 */
|
||||
const { leadsRows, leadsRawCount, leadsDeduped } = useMemo(() => {
|
||||
const normalizePhone = (p?: string | null) => (p || '').replace(/\D/g, '') || ''
|
||||
const dedupKey = (r: (typeof leadsRecords)[0]) => {
|
||||
@@ -984,7 +1071,7 @@ export function UsersPage() {
|
||||
let rows = leadsRecords
|
||||
if (q) {
|
||||
rows = leadsRecords.filter((r) => {
|
||||
const blob = [r.userNickname, r.name, r.phone, r.wechatId, r.personName, r.source, String(r.ckbPlanId ?? '')]
|
||||
const blob = [r.userNickname, r.name, r.phone, r.wechatId, r.personName, r.source, r.planApiKey]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -996,6 +1083,9 @@ export function UsersPage() {
|
||||
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return tb - ta
|
||||
})
|
||||
if (!leadsDedupEnabled) {
|
||||
return { leadsRows: sorted, leadsRawCount: rows.length, leadsDeduped: 0 }
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const out: typeof leadsRecords = []
|
||||
for (const r of sorted) {
|
||||
@@ -1005,7 +1095,7 @@ export function UsersPage() {
|
||||
out.push(r)
|
||||
}
|
||||
return { leadsRows: out, leadsRawCount: rows.length, leadsDeduped: rows.length - out.length }
|
||||
}, [leadsRecords, debouncedLeadsSearch])
|
||||
}, [leadsRecords, debouncedLeadsSearch, leadsDedupEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const pageIds = leadsRows.map((r) => r.id)
|
||||
@@ -1064,8 +1154,21 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
const pushStatusBadge = (status?: string) => {
|
||||
if (status === 'success') return <Badge className="bg-emerald-500/20 text-emerald-300 border-0 text-xs">成功</Badge>
|
||||
if (status === 'failed') return <Badge className="bg-red-500/20 text-red-300 border-0 text-xs">失败</Badge>
|
||||
const s = status || ''
|
||||
if (s === 'success')
|
||||
return <Badge className="bg-emerald-500/20 text-emerald-300 border-0 text-xs">已推送(存客宝已接收)</Badge>
|
||||
if (s === 'pending_verify')
|
||||
return <Badge className="bg-sky-500/20 text-sky-300 border-0 text-xs">待通过 / 处理中</Badge>
|
||||
if (s === 'expired') return <Badge className="bg-gray-500/20 text-gray-300 border-0 text-xs">已过期</Badge>
|
||||
if (s === 'failed') return <Badge className="bg-red-500/20 text-red-300 border-0 text-xs">失败</Badge>
|
||||
if (s === 'pending')
|
||||
return <Badge className="bg-amber-500/20 text-amber-300 border-0 text-xs">待推送</Badge>
|
||||
if (s)
|
||||
return (
|
||||
<Badge className="bg-violet-500/20 text-violet-300 border-0 text-xs" title={s}>
|
||||
{s}
|
||||
</Badge>
|
||||
)
|
||||
return <Badge className="bg-amber-500/20 text-amber-300 border-0 text-xs">待推送</Badge>
|
||||
}
|
||||
|
||||
@@ -1459,7 +1562,7 @@ export function UsersPage() {
|
||||
{!leadsLoading && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||
<div className="p-3 bg-[#0f2137] border border-gray-700/50 rounded-lg">
|
||||
<p className="text-gray-500 text-xs">总留资条数</p>
|
||||
<p className="text-gray-500 text-xs">线索总条数(含留资/加入/匹配)</p>
|
||||
<p className="text-xl font-bold text-white">{leadsTotal}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0f2137] border border-gray-700/50 rounded-lg">
|
||||
@@ -1509,7 +1612,7 @@ export function UsersPage() {
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索昵称/手机/微信/@人/来源…"
|
||||
placeholder="搜索昵称/手机/微信/@人/来源/类型…"
|
||||
value={leadsSearchTerm}
|
||||
onChange={(e) => setLeadsSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||||
@@ -1527,6 +1630,37 @@ export function UsersPage() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={leadsActionFilter}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setLeadsActionFilter(v)
|
||||
setLeadsPage(1)
|
||||
setSearchParams((prev) => {
|
||||
const p = new URLSearchParams(prev)
|
||||
p.set('tab', 'leads')
|
||||
if (v) p.set('leadAction', v)
|
||||
else p.delete('leadAction')
|
||||
return p
|
||||
})
|
||||
}}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||
title="按业务类型筛选"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="lead">留资线索(文章@ / 首页链接)</option>
|
||||
<option value="join">加入报名(导师 / 资源对接 / 团队)</option>
|
||||
<option value="match">匹配上报(找伙伴匹配)</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400 select-none bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={leadsDedupEnabled}
|
||||
onChange={(e) => setLeadsDedupEnabled(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer"
|
||||
/>
|
||||
去重展示
|
||||
</label>
|
||||
<select
|
||||
value={leadsPushStatusFilter}
|
||||
onChange={(e) => { setLeadsPushStatusFilter(e.target.value); setLeadsPage(1) }}
|
||||
@@ -1534,7 +1668,9 @@ export function UsersPage() {
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待推送</option>
|
||||
<option value="success">推送成功</option>
|
||||
<option value="success">已推送(存客宝已接收)</option>
|
||||
<option value="pending_verify">待通过 / 处理中</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="failed">推送失败</option>
|
||||
</select>
|
||||
<Button
|
||||
@@ -1623,11 +1759,10 @@ export function UsersPage() {
|
||||
<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>
|
||||
<TableHead className="text-gray-400">获客计划(Key)</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1642,17 +1777,81 @@ export function UsersPage() {
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0f2137] accent-[#38bdac] cursor-pointer"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{r.userNickname || r.name || '-'}</TableCell>
|
||||
<TableCell className="text-gray-300 align-middle">
|
||||
<LeadUserNickCell
|
||||
userId={r.userId}
|
||||
userAvatar={r.userAvatar}
|
||||
nickname={r.userNickname}
|
||||
name={r.name}
|
||||
onOpenDetail={(id) => {
|
||||
setSelectedUserIdForDetail(id)
|
||||
setShowDetailModal(true)
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{r.phone || '-'}</TableCell>
|
||||
<TableCell className="text-gray-300">{r.wechatId || '-'}</TableCell>
|
||||
<TableCell className="text-[#38bdac]">{r.personName || '-'}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.ckbPlanId ? `#${r.ckbPlanId}` : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">{r.source || '未知'}</Badge>
|
||||
<TableCell className="text-gray-400 text-xs">
|
||||
{(() => {
|
||||
const k = (r.planApiKey || '').trim()
|
||||
if (!k) return '-'
|
||||
const masked = k.length <= 10 ? k : `${k.slice(0, 6)}…${k.slice(-4)}`
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-gray-400 hover:text-gray-200 underline decoration-dotted"
|
||||
title={k}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(k)
|
||||
toast.success('已复制计划Key')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{masked}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{pushStatusBadge(r.pushStatus)}
|
||||
{(typeof r.ckbCode === 'number' || (r.ckbMessage || '').trim()) && (
|
||||
<p
|
||||
className="text-[11px] text-gray-500 max-w-[260px] truncate"
|
||||
title={[
|
||||
typeof r.ckbCode === 'number' ? `code=${r.ckbCode}` : '',
|
||||
(r.ckbMessage || '').trim() ? `message=${String(r.ckbMessage).trim()}` : '',
|
||||
(r.ckbData || '').trim() ? `data=${String(r.ckbData).trim()}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')}
|
||||
>
|
||||
{[
|
||||
typeof r.ckbCode === 'number' ? `code=${r.ckbCode}` : '',
|
||||
(r.ckbMessage || '').trim() ? String(r.ckbMessage).trim() : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{!!(r.ckbData || '').trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-sky-300/90 hover:text-sky-200 underline decoration-dotted"
|
||||
onClick={() => {
|
||||
const raw = String(r.ckbData || '').trim()
|
||||
setCkbDataDialogTitle(`存客宝返回 data(#${r.id})`)
|
||||
setCkbDataDialogContent(prettyJson(raw))
|
||||
setShowCkbDataDialog(true)
|
||||
}}
|
||||
>
|
||||
查看 data
|
||||
</button>
|
||||
)}
|
||||
{!!r.ckbError && (
|
||||
<p className="text-[11px] text-red-300 max-w-[220px] truncate" title={r.ckbError}>
|
||||
{r.ckbError}
|
||||
@@ -1660,41 +1859,45 @@ export function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400 text-xs">
|
||||
<div className="space-y-1">
|
||||
<p>{typeof r.retryCount === 'number' ? `第 ${r.retryCount} 次` : '-'}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
batchDeletingLeads || retryingLeadId === r.id || deletingLeadId === r.id
|
||||
}
|
||||
onClick={() => retryLeadPush(r.id)}
|
||||
className="h-7 px-2 text-[11px] border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${retryingLeadId === r.id ? 'animate-spin' : ''}`} />
|
||||
重推
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
batchDeletingLeads || deletingLeadId === r.id || retryingLeadId === r.id
|
||||
}
|
||||
onClick={() => deleteLeadRecord(r.id)}
|
||||
className="h-7 px-2 text-[11px] border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
|
||||
>
|
||||
<Trash2 className={`w-3 h-3 mr-1 ${deletingLeadId === r.id ? 'animate-pulse' : ''}`} />
|
||||
删除
|
||||
</Button>
|
||||
<TableCell className="text-gray-400 whitespace-nowrap">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
<TableCell className="text-gray-400 text-xs align-top py-3 min-w-[148px]">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-gray-400 leading-snug">
|
||||
{typeof r.retryCount === 'number' ? `第 ${r.retryCount} 次` : '-'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
batchDeletingLeads || retryingLeadId === r.id || deletingLeadId === r.id
|
||||
}
|
||||
onClick={() => retryLeadPush(r.id)}
|
||||
className="h-8 px-2.5 text-[11px] border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent shrink-0"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${retryingLeadId === r.id ? 'animate-spin' : ''}`} />
|
||||
重推
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
batchDeletingLeads || deletingLeadId === r.id || retryingLeadId === r.id
|
||||
}
|
||||
onClick={() => deleteLeadRecord(r.id)}
|
||||
className="h-8 px-2.5 text-[11px] border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent shrink-0"
|
||||
>
|
||||
<Trash2 className={`w-3 h-3 mr-1 ${deletingLeadId === r.id ? 'animate-pulse' : ''}`} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{leadsRows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="p-0 align-top">
|
||||
<TableCell colSpan={9} className="p-0 align-top">
|
||||
<div className="py-16 px-6 text-center border-t border-gray-700/40 bg-[#0a1628]/30">
|
||||
<LeadIcon className="w-14 h-14 text-[#38bdac]/20 mx-auto mb-4" aria-hidden />
|
||||
<p className="text-gray-200 font-medium mb-1">暂无获客线索</p>
|
||||
@@ -1817,20 +2020,27 @@ export function UsersPage() {
|
||||
</div>
|
||||
) : Object.keys(journeyStats).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{JOURNEY_STAGES.map((stage) => {
|
||||
const count = journeyStats[stage.id] || 0
|
||||
const maxCount = Math.max(...JOURNEY_STAGES.map(s => journeyStats[s.id] || 0), 1)
|
||||
const pct = Math.round((count / maxCount) * 100)
|
||||
return (
|
||||
<div key={stage.id} className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs w-20 shrink-0">{stage.icon} {stage.label}</span>
|
||||
<div className="flex-1 h-2 bg-[#0a1628] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#38bdac]/60 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
{(() => {
|
||||
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 leading-tight">{stage.icon} {stage.label}</span>
|
||||
<div className="flex-1 h-2.5 bg-[#0a1628] 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>
|
||||
<span className="text-gray-400 text-xs w-10 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
@@ -2408,6 +2618,43 @@ export function UsersPage() {
|
||||
</Dialog>
|
||||
|
||||
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
|
||||
<Dialog open={showCkbDataDialog} onOpenChange={setShowCkbDataDialog}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700/60 text-white max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">{ckbDataDialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-2">
|
||||
<pre className="text-xs text-gray-200 bg-[#0a1628] border border-gray-700/60 rounded-lg p-3 max-h-[520px] overflow-auto whitespace-pre-wrap break-words">
|
||||
{ckbDataDialogContent || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ckbDataDialogContent || '')
|
||||
toast.success('已复制 data')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
}}
|
||||
disabled={!ckbDataDialogContent}
|
||||
>
|
||||
复制 data
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#38bdac] hover:bg-[#2aa896] text-white"
|
||||
onClick={() => setShowCkbDataDialog(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,9 +86,6 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
|
||||
log.Printf("database: admin_users migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CkbSubmitRecord{}); err != nil {
|
||||
log.Printf("database: ckb_submit_records migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
|
||||
log.Printf("database: ckb_lead_records migrate warning: %v", err)
|
||||
}
|
||||
@@ -158,6 +155,36 @@ func ensureCkbLeadSchema(db *gorm.DB) {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add push_status", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "action") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN action VARCHAR(20) NOT NULL DEFAULT 'lead' COMMENT '记录类型: lead/join/match'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add action", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "plan_api_key") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN plan_api_key VARCHAR(100) NOT NULL DEFAULT '' COMMENT '本次命中的获客计划 apiKey 快照'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add plan_api_key", err)
|
||||
}
|
||||
}
|
||||
if !m.HasIndex(&model.CkbLeadRecord{}, "idx_ckb_lead_action") {
|
||||
if err := db.Exec("CREATE INDEX idx_ckb_lead_action ON ckb_lead_records(action)").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_action", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_code") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_code INT NOT NULL DEFAULT 0 COMMENT '存客宝响应 code(快照)'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_code", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_message") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_message VARCHAR(500) NOT NULL DEFAULT '' COMMENT '存客宝响应 message(快照)'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_message", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_data") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_data TEXT NULL COMMENT '存客宝响应 data(快照 JSON)'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_data", err)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.CkbLeadRecord{}, "retry_count") {
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add retry_count", err)
|
||||
@@ -178,4 +205,8 @@ func ensureCkbLeadSchema(db *gorm.DB) {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_push_status", err)
|
||||
}
|
||||
}
|
||||
// 放宽 push_status 长度以容纳存客宝细粒度状态(pending_verify、expired 等)
|
||||
if err := db.Exec("ALTER TABLE ckb_lead_records MODIFY COLUMN push_status VARCHAR(64) NOT NULL DEFAULT 'pending' COMMENT '推送状态'").Error; err != nil {
|
||||
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=widen push_status", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,11 +398,12 @@ func AdminDashboardLeads(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var contactTotal, submitTotal, uniqueContactUsers int64
|
||||
db.Model(&model.CkbLeadRecord{}).Count(&contactTotal)
|
||||
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
|
||||
// 统一到 ckb_lead_records:join/match 也在同表,用 action!=lead 视作“已提交线索”
|
||||
db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"}).Count(&submitTotal)
|
||||
db.Raw(`SELECT COUNT(DISTINCT user_id) FROM ckb_lead_records WHERE user_id IS NOT NULL AND user_id != ''`).Scan(&uniqueContactUsers)
|
||||
var todayContact, todaySubmit int64
|
||||
db.Raw(`SELECT COUNT(*) FROM ckb_lead_records WHERE DATE(created_at) = CURDATE()`).Scan(&todayContact)
|
||||
db.Raw(`SELECT COUNT(*) FROM ckb_submit_records WHERE DATE(created_at) = CURDATE()`).Scan(&todaySubmit)
|
||||
db.Raw(`SELECT COUNT(*) FROM ckb_lead_records WHERE action IN ('join','match') AND DATE(created_at) = CURDATE()`).Scan(&todaySubmit)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
|
||||
@@ -34,15 +34,25 @@ const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
|
||||
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
|
||||
|
||||
// ckbSubmitSave 加好友/留资类接口统一落库:记录 action、userId、昵称、用户提交的传参,写入 ckb_submit_records
|
||||
func ckbSubmitSave(action, userID, nickname string, params interface{}) {
|
||||
// ckbLeadSaveUnified 将 join/match 等也落到 ckb_lead_records(与 lead 同表),用于后台统一查看推送状态与存客宝响应快照
|
||||
func ckbLeadSaveUnified(action, userID, nickname, phone, wechatId, source, planAPIKey string, params interface{}) int64 {
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
_ = database.DB().Create(&model.CkbSubmitRecord{
|
||||
Action: action,
|
||||
UserID: userID,
|
||||
Nickname: nickname,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
rec := model.CkbLeadRecord{
|
||||
Action: strings.TrimSpace(action),
|
||||
UserID: strings.TrimSpace(userID),
|
||||
Nickname: strings.TrimSpace(nickname),
|
||||
Phone: strings.TrimSpace(phone),
|
||||
WechatID: strings.TrimSpace(wechatId),
|
||||
Source: strings.TrimSpace(source),
|
||||
PlanAPIKey: strings.TrimSpace(planAPIKey),
|
||||
Params: string(paramsJSON),
|
||||
PushStatus: "pending",
|
||||
}
|
||||
if rec.Action == "" {
|
||||
rec.Action = "lead"
|
||||
}
|
||||
_ = database.DB().Create(&rec).Error
|
||||
return rec.ID
|
||||
}
|
||||
|
||||
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey)
|
||||
@@ -114,17 +124,114 @@ func getCkbLeadApiKey() string {
|
||||
return ckbAPIKey
|
||||
}
|
||||
|
||||
func markLeadPushSuccess(db *gorm.DB, recordID int64) {
|
||||
// inferCkbLeadPushStatusFromText 根据存客宝返回文案推断细粒度状态(与后台「获客列表」展示对齐)
|
||||
func inferCkbLeadPushStatusFromText(msg string) string {
|
||||
m := strings.TrimSpace(msg)
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
ml := strings.ToLower(m)
|
||||
if strings.Contains(m, "过期") || strings.Contains(ml, "expired") {
|
||||
return "expired"
|
||||
}
|
||||
if (strings.Contains(m, "待") && (strings.Contains(m, "通过") || strings.Contains(m, "验证") || strings.Contains(m, "审核"))) ||
|
||||
strings.Contains(ml, "pending") || strings.Contains(ml, "queue") {
|
||||
return "pending_verify"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeCkbDataPushStatus(v string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(v))
|
||||
switch s {
|
||||
case "success", "ok", "done", "sent":
|
||||
return "success"
|
||||
case "pending", "waiting", "processing", "queued":
|
||||
return "pending_verify"
|
||||
case "expired", "invalid":
|
||||
return "expired"
|
||||
case "failed", "fail":
|
||||
return "failed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// applyCkbLeadPushOutcome 根据存客宝 HTTP 返回更新推送状态(success / pending_verify / expired / failed 等)
|
||||
func applyCkbLeadPushOutcome(db *gorm.DB, recordID int64, code int, message string, data interface{}) {
|
||||
if db == nil || recordID <= 0 {
|
||||
return
|
||||
}
|
||||
msg := strings.TrimSpace(message)
|
||||
dataJSON := ""
|
||||
if data != nil {
|
||||
if b, err := json.Marshal(data); err == nil {
|
||||
dataJSON = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if code == 200 {
|
||||
status := "success"
|
||||
if dm, ok := data.(map[string]interface{}); ok {
|
||||
for _, key := range []string{"pushStatus", "push_status", "status", "leadStatus", "state"} {
|
||||
if raw, ok := dm[key].(string); ok {
|
||||
if n := normalizeCkbDataPushStatus(raw); n != "" {
|
||||
status = n
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == "success" {
|
||||
if t := inferCkbLeadPushStatusFromText(msg); t != "" {
|
||||
status = t
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
|
||||
"push_status": status,
|
||||
"ckb_code": code,
|
||||
"ckb_message": msg,
|
||||
"ckb_data": dataJSON,
|
||||
"ckb_error": "",
|
||||
"last_push_at": now,
|
||||
"next_retry_at": nil,
|
||||
}).Error
|
||||
return
|
||||
}
|
||||
if code == 201 || code == 202 {
|
||||
st := "pending_verify"
|
||||
if t := inferCkbLeadPushStatusFromText(msg); t != "" {
|
||||
st = t
|
||||
}
|
||||
now := time.Now()
|
||||
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
|
||||
"push_status": st,
|
||||
"ckb_code": code,
|
||||
"ckb_message": msg,
|
||||
"ckb_data": dataJSON,
|
||||
"ckb_error": msg,
|
||||
"last_push_at": now,
|
||||
"next_retry_at": nil,
|
||||
}).Error
|
||||
return
|
||||
}
|
||||
errMsg := msg
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("存客宝返回 code=%d", code)
|
||||
}
|
||||
// code!=200:落库响应快照 + 标记失败
|
||||
now := time.Now()
|
||||
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
|
||||
"push_status": "success",
|
||||
"ckb_error": "",
|
||||
updates := map[string]interface{}{
|
||||
"push_status": "failed",
|
||||
"ckb_code": code,
|
||||
"ckb_message": msg,
|
||||
"ckb_data": dataJSON,
|
||||
"ckb_error": strings.TrimSpace(errMsg),
|
||||
"last_push_at": now,
|
||||
"next_retry_at": nil,
|
||||
}).Error
|
||||
"next_retry_at": now.Add(5 * time.Minute),
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
}
|
||||
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func markLeadPushFailed(db *gorm.DB, recordID int64, errMsg string, incRetry bool) {
|
||||
@@ -134,6 +241,9 @@ func markLeadPushFailed(db *gorm.DB, recordID int64, errMsg string, incRetry boo
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"push_status": "failed",
|
||||
"ckb_code": 0,
|
||||
"ckb_message": "",
|
||||
"ckb_data": "",
|
||||
"ckb_error": strings.TrimSpace(errMsg),
|
||||
"last_push_at": now,
|
||||
"next_retry_at": now.Add(5 * time.Minute),
|
||||
@@ -216,6 +326,38 @@ func resolvePersonForLead(db *gorm.DB, targetUserID string) (model.Person, bool)
|
||||
return model.Person{}, false
|
||||
}
|
||||
|
||||
// existsUnifiedLeadRecent join/match 幂等去重:同用户+动作+来源+联系方式在窗口期内仅保留一条,避免重复点击刷数据
|
||||
func existsUnifiedLeadRecent(db *gorm.DB, action, userID, source, phone, wechatID string, within time.Duration) bool {
|
||||
if db == nil {
|
||||
return false
|
||||
}
|
||||
action = strings.TrimSpace(action)
|
||||
userID = strings.TrimSpace(userID)
|
||||
source = strings.TrimSpace(source)
|
||||
phone = strings.TrimSpace(phone)
|
||||
wechatID = strings.TrimSpace(wechatID)
|
||||
if action == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
if phone == "" && wechatID == "" {
|
||||
return false
|
||||
}
|
||||
q := db.Model(&model.CkbLeadRecord{}).
|
||||
Where("action = ? AND user_id = ? AND source = ?", action, userID, source)
|
||||
if phone != "" {
|
||||
q = q.Where("phone = ?", phone)
|
||||
}
|
||||
if wechatID != "" {
|
||||
q = q.Where("wechat_id = ?", wechatID)
|
||||
}
|
||||
if within > 0 {
|
||||
q = q.Where("created_at >= ?", time.Now().Add(-within))
|
||||
}
|
||||
var n int64
|
||||
q.Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -248,7 +390,10 @@ func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord)
|
||||
wechatId = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
leadKey := getCkbLeadApiKey()
|
||||
leadKey := strings.TrimSpace(r.PlanAPIKey)
|
||||
if leadKey == "" {
|
||||
leadKey = getCkbLeadApiKey()
|
||||
}
|
||||
targetName := ""
|
||||
targetMemberID := ""
|
||||
targetMemberName := ""
|
||||
@@ -282,7 +427,7 @@ func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord)
|
||||
return false
|
||||
}
|
||||
if res.Code == 200 {
|
||||
markLeadPushSuccess(db, r.ID)
|
||||
applyCkbLeadPushOutcome(db, r.ID, res.Code, res.Message, nil)
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
@@ -366,10 +511,10 @@ func CKBJoin(c *gin.Context) {
|
||||
}
|
||||
if body.Type == "investor" && body.UserID != "" {
|
||||
if !userHasContentPurchase(database.DB(), body.UserID) {
|
||||
// 交互原则:用户侧友好提示(不暴露存客宝/规则细节);后台可通过规则引导再完善
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请先购买章节或解锁全书后再使用资源对接",
|
||||
"errorCode": "ERR_REQUIRE_PURCHASE",
|
||||
"success": true,
|
||||
"message": "提交成功,我们会尽快联系您",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -384,10 +529,18 @@ func CKBJoin(c *gin.Context) {
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("join", body.UserID, nickname, map[string]interface{}{
|
||||
submitParams := map[string]interface{}{
|
||||
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
|
||||
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
|
||||
})
|
||||
}
|
||||
joinSource := "join_" + body.Type
|
||||
// 幂等:同用户在短时间内重复点击同类加入,不再重复落库
|
||||
if existsUnifiedLeadRecent(database.DB(), "join", body.UserID, joinSource, body.Phone, body.Wechat, 10*time.Minute) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您", "data": gin.H{"repeatedSubmit": true}})
|
||||
return
|
||||
}
|
||||
// 同步写入统一线索表(便于后台统一推送状态/响应快照)
|
||||
leadID := ckbLeadSaveUnified("join", body.UserID, nickname, body.Phone, body.Wechat, joinSource, ckbAPIKey, submitParams)
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
@@ -435,7 +588,9 @@ func CKBJoin(c *gin.Context) {
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
|
||||
// 用户侧保持友好:无论存客宝是否响应,都提示提交成功;后台用 push_status/ckb_error 追踪
|
||||
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -447,6 +602,7 @@ func CKBJoin(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
|
||||
// 资源对接:同步更新用户资料中的 help_offer、help_need、phone、wechat_id
|
||||
if body.Type == "investor" && body.UserID != "" {
|
||||
updates := map[string]interface{}{}
|
||||
@@ -477,10 +633,12 @@ func CKBJoin(c *gin.Context) {
|
||||
if errMsg == "" {
|
||||
errMsg = "加入失败,请稍后重试"
|
||||
}
|
||||
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
|
||||
// 打印 CKB 原始响应便于排查
|
||||
fmt.Printf("[CKBJoin] 失败 type=%s wechat=%s code=%d message=%s raw=%s\n",
|
||||
body.Type, body.Wechat, result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
|
||||
// 用户侧仍提示提交成功
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您"})
|
||||
}
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
@@ -502,10 +660,16 @@ func CKBMatch(c *gin.Context) {
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("match", body.UserID, nickname, map[string]interface{}{
|
||||
submitParams := map[string]interface{}{
|
||||
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
|
||||
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
|
||||
})
|
||||
}
|
||||
matchSource := "match_" + body.MatchType
|
||||
if existsUnifiedLeadRecent(database.DB(), "match", body.UserID, matchSource, body.Phone, body.Wechat, 5*time.Minute) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功", "data": gin.H{"repeatedSubmit": true}})
|
||||
return
|
||||
}
|
||||
leadID := ckbLeadSaveUnified("match", body.UserID, nickname, body.Phone, body.Wechat, matchSource, ckbAPIKey, submitParams)
|
||||
ts := time.Now().Unix()
|
||||
label := ckbSourceMap[body.MatchType]
|
||||
if label == "" {
|
||||
@@ -542,7 +706,8 @@ func CKBMatch(c *gin.Context) {
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBMatch] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -553,11 +718,13 @@ func CKBMatch(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil})
|
||||
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功", "data": nil})
|
||||
return
|
||||
}
|
||||
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
|
||||
fmt.Printf("[CKBMatch] 存客宝返回异常 code=%d message=%s\n", result.Code, result.Message)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功"})
|
||||
}
|
||||
|
||||
// CKBSync GET/POST /api/ckb/sync
|
||||
@@ -616,15 +783,22 @@ func CKBIndexLead(c *gin.Context) {
|
||||
"source": source,
|
||||
})
|
||||
rec := model.CkbLeadRecord{
|
||||
Action: "lead",
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Source: source,
|
||||
PlanAPIKey: leadKey,
|
||||
Params: string(paramsJSON),
|
||||
PushStatus: "pending",
|
||||
}
|
||||
// 与全局 leadKey 绑定的 Person(首页「链接卡若」),写入 target_person_id 便于后台「对应 @人」展示
|
||||
var bindPerson model.Person
|
||||
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&bindPerson).Error == nil && strings.TrimSpace(bindPerson.PersonID) != "" {
|
||||
rec.TargetPersonID = bindPerson.PersonID
|
||||
}
|
||||
_ = db.Create(&rec).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
@@ -659,7 +833,7 @@ func CKBIndexLead(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
markLeadPushSuccess(db, rec.ID)
|
||||
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
|
||||
var msg string
|
||||
var defaultPerson model.Person
|
||||
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&defaultPerson).Error == nil && strings.TrimSpace(defaultPerson.Tips) != "" {
|
||||
@@ -805,6 +979,7 @@ func CKBLead(c *gin.Context) {
|
||||
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
|
||||
})
|
||||
rec := model.CkbLeadRecord{
|
||||
Action: "lead",
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
@@ -812,6 +987,7 @@ func CKBLead(c *gin.Context) {
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
TargetPersonID: body.TargetUserID,
|
||||
Source: source,
|
||||
PlanAPIKey: leadKey,
|
||||
Params: string(paramsJSON),
|
||||
PushStatus: "pending",
|
||||
}
|
||||
@@ -860,7 +1036,7 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
markLeadPushSuccess(db, rec.ID)
|
||||
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
|
||||
who := targetName
|
||||
if who == "" {
|
||||
who = "对方"
|
||||
@@ -941,6 +1117,8 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(rb, &rr)
|
||||
if rr.Code == 200 {
|
||||
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", rec.ID).Update("plan_api_key", globalKey).Error
|
||||
applyCkbLeadPushOutcome(db, rec.ID, rr.Code, rr.Message, rr.Data)
|
||||
who := targetName
|
||||
if who == "" {
|
||||
who = "对方"
|
||||
@@ -962,7 +1140,6 @@ func CKBLead(c *gin.Context) {
|
||||
Repeated: false,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
markLeadPushSuccess(db, rec.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ func defaultMpUi() gin.H {
|
||||
"homePage": gin.H{
|
||||
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
|
||||
"linkKaruoText": "点击链接卡若", "linkKaruoAvatar": "",
|
||||
"pinnedTitlePrefix": "派对会员",
|
||||
"pinnedMainTitleTemplate": "",
|
||||
"searchPlaceholder": "搜索章节标题或内容...",
|
||||
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
|
||||
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
|
||||
@@ -161,6 +163,15 @@ func defaultMpUi() gin.H {
|
||||
"readStatPath": "/pages/reading-records/reading-records?focus=all",
|
||||
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
|
||||
},
|
||||
"memberDetailPage": gin.H{
|
||||
"unlockIntroTitle": "解锁与链接说明",
|
||||
"unlockIntroBody": "「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。请先阅读说明,确认后再登录。",
|
||||
},
|
||||
"readPage": gin.H{
|
||||
"beforeLoginHint": "试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。",
|
||||
"singlePageTitle": "解锁全文",
|
||||
"singlePagePaywallHint": "当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
// mode=submitted: ckb_submit_records(join/match 提交)
|
||||
// mode=submitted: ckb_lead_records(action=join/match,兼容旧面板命名)
|
||||
// mode=contact: ckb_lead_records(链接卡若留资,有 phone/wechat)
|
||||
func DBCKBLeadList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -31,6 +31,7 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
if mode == "contact" {
|
||||
search := c.Query("search")
|
||||
source := c.Query("source")
|
||||
action := strings.TrimSpace(c.Query("action"))
|
||||
pushStatus := strings.TrimSpace(c.Query("pushStatus"))
|
||||
q := db.Model(&model.CkbLeadRecord{})
|
||||
if search != "" {
|
||||
@@ -40,6 +41,9 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
if source != "" {
|
||||
q = q.Where("source = ?", source)
|
||||
}
|
||||
if action != "" {
|
||||
q = q.Where("action = ?", action)
|
||||
}
|
||||
if pushStatus != "" {
|
||||
q = q.Where("push_status = ?", pushStatus)
|
||||
}
|
||||
@@ -85,13 +89,34 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
userMap[urows[i].ID] = &urows[i]
|
||||
}
|
||||
}
|
||||
// 首页 index_link_button 历史数据可能未写 target_person_id:用全局 leadKey 对应 Person 回退展示
|
||||
var indexLinkFallback *model.Person
|
||||
leadKey := getCkbLeadApiKey()
|
||||
if leadKey != "" {
|
||||
var fp model.Person
|
||||
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&fp).Error == nil {
|
||||
indexLinkFallback = &fp
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
// 兜底:历史数据 plan_api_key 可能为空(已迁移但仍有漏网),按 action 回填展示,不改库
|
||||
planKey := strings.TrimSpace(r.PlanAPIKey)
|
||||
if planKey == "" {
|
||||
if strings.TrimSpace(r.Action) == "join" || strings.TrimSpace(r.Action) == "match" {
|
||||
planKey = ckbAPIKey
|
||||
} else if strings.TrimSpace(r.Action) == "lead" && r.Source == "index_link_button" {
|
||||
planKey = getCkbLeadApiKey()
|
||||
}
|
||||
}
|
||||
personName := ""
|
||||
ckbPlanId := int64(0)
|
||||
if p := personMap[r.TargetPersonID]; p != nil {
|
||||
personName = p.Name
|
||||
ckbPlanId = p.CkbPlanID
|
||||
} else if strings.TrimSpace(r.TargetPersonID) == "" && r.Source == "index_link_button" && indexLinkFallback != nil {
|
||||
personName = indexLinkFallback.Name
|
||||
ckbPlanId = indexLinkFallback.CkbPlanID
|
||||
}
|
||||
displayNick := r.Nickname
|
||||
userAvatar := ""
|
||||
@@ -103,6 +128,7 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"action": r.Action,
|
||||
"userId": r.UserID,
|
||||
"userNickname": displayNick,
|
||||
"userAvatar": userAvatar,
|
||||
@@ -111,11 +137,15 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"source": r.Source,
|
||||
"planApiKey": planKey,
|
||||
"targetPersonId": r.TargetPersonID,
|
||||
"personName": personName,
|
||||
"ckbPlanId": ckbPlanId,
|
||||
"pushStatus": r.PushStatus,
|
||||
"retryCount": r.RetryCount,
|
||||
"ckbCode": r.CkbCode,
|
||||
"ckbMessage": r.CkbMessage,
|
||||
"ckbData": r.CkbData,
|
||||
"ckbError": r.CkbError,
|
||||
"lastPushAt": r.LastPushAt,
|
||||
"nextRetryAt": r.NextRetryAt,
|
||||
@@ -142,18 +172,16 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// mode=submitted: ckb_submit_records
|
||||
q := db.Model(&model.CkbSubmitRecord{})
|
||||
// mode=submitted: 兼容旧面板,统一从 ckb_lead_records 中读取 join/match
|
||||
q := db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"})
|
||||
if matchType != "" {
|
||||
// matchType 对应 action: join 时 type 在 params 中,match 时 matchType 在 params 中
|
||||
// 简化:仅按 action 过滤,join 时 params 含 type
|
||||
if matchType == "join" || matchType == "match" {
|
||||
q = q.Where("action = ?", matchType)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbSubmitRecord
|
||||
var records []model.CkbLeadRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
@@ -228,6 +256,9 @@ func DBCKBLeadRetry(c *gin.Context) {
|
||||
"id": r.ID,
|
||||
"pushStatus": r.PushStatus,
|
||||
"retryCount": r.RetryCount,
|
||||
"ckbCode": r.CkbCode,
|
||||
"ckbMessage": r.CkbMessage,
|
||||
"ckbData": r.CkbData,
|
||||
"ckbError": r.CkbError,
|
||||
"lastPushAt": r.LastPushAt,
|
||||
"nextRetryAt": r.NextRetryAt,
|
||||
@@ -301,7 +332,7 @@ func DBCKBLeadDeleteBatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "deleted": res.RowsAffected})
|
||||
}
|
||||
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records)
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(统一基于 ckb_lead_records)
|
||||
func CKBPlanStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
type TypeStat struct {
|
||||
@@ -309,12 +340,12 @@ func CKBPlanStats(c *gin.Context) {
|
||||
Total int64 `gorm:"column:total" json:"total"`
|
||||
}
|
||||
var submitStats []TypeStat
|
||||
db.Raw("SELECT action, COUNT(*) as total FROM ckb_submit_records GROUP BY action").Scan(&submitStats)
|
||||
db.Raw("SELECT action, COUNT(*) as total FROM ckb_lead_records WHERE action IN ('join','match') GROUP BY action").Scan(&submitStats)
|
||||
var submitTotal int64
|
||||
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
|
||||
db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"}).Count(&submitTotal)
|
||||
var leadTotal int64
|
||||
db.Model(&model.CkbLeadRecord{}).Count(&leadTotal)
|
||||
withContact := leadTotal // ckb_lead_records 均有 phone 或 wechat
|
||||
db.Model(&model.CkbLeadRecord{}).Where("action = ?", "lead").Count(&leadTotal)
|
||||
withContact := leadTotal // lead 记录均有 phone 或 wechat
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
|
||||
@@ -76,24 +76,25 @@ func MiniprogramMentorsDetail(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": m.ID,
|
||||
"avatar": resolveAvatarURL(m.Avatar),
|
||||
"name": m.Name,
|
||||
"intro": m.Intro,
|
||||
"tags": m.Tags,
|
||||
"tagsArr": tagsArr,
|
||||
"priceSingle": m.PriceSingle,
|
||||
"priceHalfYear": m.PriceHalfYear,
|
||||
"priceYear": m.PriceYear,
|
||||
"quote": m.Quote,
|
||||
"whyFind": m.WhyFind,
|
||||
"offering": m.Offering,
|
||||
"judgmentStyle": m.JudgmentStyle,
|
||||
},
|
||||
})
|
||||
out := gin.H{
|
||||
"id": m.ID,
|
||||
"avatar": resolveAvatarURL(m.Avatar),
|
||||
"name": m.Name,
|
||||
"intro": m.Intro,
|
||||
"tags": m.Tags,
|
||||
"tagsArr": tagsArr,
|
||||
"priceSingle": m.PriceSingle,
|
||||
"priceHalfYear": m.PriceHalfYear,
|
||||
"priceYear": m.PriceYear,
|
||||
"quote": m.Quote,
|
||||
"whyFind": m.WhyFind,
|
||||
"offering": m.Offering,
|
||||
"judgmentStyle": m.JudgmentStyle,
|
||||
}
|
||||
if m.UserID != nil && strings.TrimSpace(*m.UserID) != "" {
|
||||
out["userId"] = strings.TrimSpace(*m.UserID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// MiniprogramMentorsBook POST /api/miniprogram/mentors/:id/book 创建预约(选择咨询类型后创建,后续走支付)
|
||||
@@ -200,6 +201,7 @@ func DBMentorsAction(c *gin.Context) {
|
||||
JudgmentStyle string `json:"judgmentStyle"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"})
|
||||
@@ -220,6 +222,9 @@ func DBMentorsAction(c *gin.Context) {
|
||||
Sort: body.Sort,
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
if uid := strings.TrimSpace(body.UserID); uid != "" {
|
||||
m.UserID = &uid
|
||||
}
|
||||
if m.Enabled == nil {
|
||||
trueVal := true
|
||||
m.Enabled = &trueVal
|
||||
@@ -248,6 +253,7 @@ func DBMentorsAction(c *gin.Context) {
|
||||
JudgmentStyle *string `json:"judgmentStyle"`
|
||||
Sort *int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
UserID *string `json:"userId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
|
||||
@@ -293,6 +299,14 @@ func DBMentorsAction(c *gin.Context) {
|
||||
if body.Enabled != nil {
|
||||
updates["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.UserID != nil {
|
||||
uid := strings.TrimSpace(*body.UserID)
|
||||
if uid == "" {
|
||||
updates["user_id"] = nil
|
||||
} else {
|
||||
updates["user_id"] = uid
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "无更新"})
|
||||
return
|
||||
|
||||
@@ -5,18 +5,24 @@ import "time"
|
||||
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
|
||||
type CkbLeadRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Action string `gorm:"column:action;size:20;not null;default:'lead';index" json:"action"` // lead | join | match
|
||||
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
|
||||
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
|
||||
Phone string `gorm:"column:phone;size:20" json:"phone"`
|
||||
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
|
||||
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
|
||||
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
|
||||
Source string `gorm:"column:source;size:50" json:"source"` // 来源:index_lead / article_mention
|
||||
Source string `gorm:"column:source;size:50" json:"source"` // 来源:index_link_button / article_mention / join_team / match_partner ...
|
||||
PlanAPIKey string `gorm:"column:plan_api_key;size:100;default:''" json:"planApiKey"` // 本次命中的获客计划 apiKey 快照
|
||||
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
|
||||
PushStatus string `gorm:"column:push_status;size:20;default:'pending';index" json:"pushStatus"` // pending/success/failed
|
||||
PushStatus string `gorm:"column:push_status;size:64;default:'pending';index" json:"pushStatus"` // pending/success/failed/pending_verify/expired 等
|
||||
RetryCount int `gorm:"column:retry_count;default:0" json:"retryCount"`
|
||||
LastPushAt *time.Time `gorm:"column:last_push_at" json:"lastPushAt,omitempty"`
|
||||
NextRetryAt *time.Time `gorm:"column:next_retry_at" json:"nextRetryAt,omitempty"`
|
||||
// 存客宝响应快照:用于后台展示排障(不依赖「推断状态」)
|
||||
CkbCode int `gorm:"column:ckb_code;default:0" json:"ckbCode"`
|
||||
CkbMessage string `gorm:"column:ckb_message;size:500;default:''" json:"ckbMessage"`
|
||||
CkbData string `gorm:"column:ckb_data;type:text" json:"ckbData"`
|
||||
CkbError string `gorm:"column:ckb_error;size:500" json:"ckbError"` // 存客宝请求失败时写入错误信息,便于运营排查
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ type Mentor struct {
|
||||
JudgmentStyle string `gorm:"column:judgment_style;size:500" json:"judgmentStyle"` // 判断风格,逗号分隔
|
||||
Sort int `gorm:"column:sort;default:0" json:"sort"`
|
||||
Enabled *bool `gorm:"column:enabled;default:1" json:"enabled"`
|
||||
// UserID 绑定小程序用户 id,与超级个体名片同一路径(member-detail?id=)
|
||||
UserID *string `gorm:"column:user_id;size:36;index" json:"userId,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user