更新首页逻辑以支持动态标题生成,优化用户体验。调整管理后台资源文件,替换旧的 JavaScript 和 CSS 文件,提升页面性能和样式一致性。同时,更新数据库结构以支持更细粒度的推送状态。

This commit is contained in:
Alex-larget
2026-03-26 20:26:35 +08:00
parent af05740d6f
commit 7bf301a9c8
34 changed files with 1994 additions and 1774 deletions

View File

@@ -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 (_) {}
},
/** 拉取后台置顶 @人物,合并到首页右上角「链接」区 */

View File

@@ -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
}

View File

@@ -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))}` })
},
})

View File

@@ -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}}">

View File

@@ -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; }

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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避免重复向导。

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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) {

View File

@@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1010
soul-admin/dist/assets/index-i0PBc3Gp.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
)}
</>
)}

View File

@@ -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 />}

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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)
}
}

View File

@@ -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_recordsjoin/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{

View File

@@ -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
}

View File

@@ -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": "当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。",
},
}
}

View File

@@ -13,7 +13,7 @@ import (
)
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
// mode=submitted: ckb_submit_recordsjoin/match 提交
// mode=submitted: ckb_lead_recordsaction=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{

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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"`
}