From 48009850dc82292cf413fac93b7db129a68f4843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=8B=A5?= Date: Sun, 8 Mar 2026 08:24:39 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20soul-admin=20=E7=BB=84=E4=BB=B6=20|=20?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0:=20=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/user/UserDetailModal.tsx | 616 +++++++++++++++--- 1 file changed, 532 insertions(+), 84 deletions(-) diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index bff93ab1..aa4474d1 100644 --- a/soul-admin/src/components/modules/user/UserDetailModal.tsx +++ b/soul-admin/src/components/modules/user/UserDetailModal.tsx @@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { User, Phone, - History, + MapPin, RefreshCw, Link2, BookOpen, @@ -24,6 +24,12 @@ import { Save, X, Tag, + TrendingUp, + Zap, + Search, + CheckCircle2, + Crown, + Navigation, } from 'lucide-react' import { get, put, post } from '@/api/client' @@ -60,6 +66,12 @@ interface UserDetail { vipProject?: string | null vipContact?: string | null vipBio?: string | null + vipRole?: string | null + // 扩展字段 + mbti?: string + region?: string + industry?: string + position?: string } interface UserTrack { @@ -72,6 +84,23 @@ interface UserTrack { timeAgo: string } +interface RFMData { + rfmScore: number + rfmLevel: string + recency: number + frequency: number + monetary: number + lastOrderAt?: string +} + +interface ShensheShouData { + rfm_score?: number + user_level?: string + tags?: string[] + last_active?: string + phone?: string +} + export function UserDetailModal({ open, onClose, @@ -81,6 +110,7 @@ export function UserDetailModal({ const [user, setUser] = useState(null) const [tracks, setTracks] = useState([]) const [referrals, setReferrals] = useState([]) + const [rfmData, setRfmData] = useState(null) const [loading, setLoading] = useState(false) const [syncing, setSyncing] = useState(false) const [saving, setSaving] = useState(false) @@ -90,8 +120,23 @@ export function UserDetailModal({ const [editTags, setEditTags] = useState([]) const [newTag, setNewTag] = useState('') + // 神射手 + const [sssLoading, setSssLoading] = useState(false) + const [sssData, setSssData] = useState(null) + const [sssError, setSssError] = useState(null) + const [sssQueryPhone, setSssQueryPhone] = useState('') + const [batchIngestLoading, setBatchIngestLoading] = useState(false) + const [batchIngestResult, setBatchIngestResult] = useState | null>(null) + useEffect(() => { - if (open && userId) loadUserDetail() + if (open && userId) { + setActiveTab('info') + setSssData(null) + setSssError(null) + setBatchIngestResult(null) + setRfmData(null) + loadUserDetail() + } }, [open, userId]) async function loadUserDetail() { @@ -106,24 +151,34 @@ export function UserDetailModal({ setUser(u) setEditPhone(u.phone || '') setEditNickname(u.nickname || '') - setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : []) + setSssQueryPhone(u.phone || '') + try { + setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : []) + } catch { + setEditTags([]) + } } + // 行为轨迹(用户旅程) try { const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>( `/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`, ) if (trackData?.success && trackData.tracks) setTracks(trackData.tracks) - } catch { - setTracks([]) - } + } catch { setTracks([]) } + // 关系链路 try { const refData = await get<{ success?: boolean; referrals?: unknown[] }>( `/api/db/users/referrals?userId=${encodeURIComponent(userId)}`, ) if (refData?.success && refData.referrals) setReferrals(refData.referrals) - } catch { - setReferrals([]) - } + } catch { setReferrals([]) } + // RFM 数据 + try { + const rfm = await get<{ success?: boolean; rfm?: RFMData }>( + `/api/db/users/rfm-single?userId=${encodeURIComponent(userId)}`, + ) + if (rfm?.success && rfm.rfm) setRfmData(rfm.rfm) + } catch { setRfmData(null) } } catch (e) { console.error('Load user detail error:', e) } finally { @@ -132,10 +187,7 @@ export function UserDetailModal({ } async function handleSyncCKB() { - if (!user?.phone) { - alert('用户未绑定手机号,无法同步') - return - } + if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return } setSyncing(true) try { const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', { @@ -143,12 +195,8 @@ export function UserDetailModal({ phone: user.phone, userId: user.id, }) - if (data?.success) { - alert('同步成功') - loadUserDetail() - } else { - alert('同步失败: ' + (data as { error?: string })?.error) - } + if (data?.success) { alert('同步成功'); loadUserDetail() } + else alert('同步失败: ' + (data as { error?: string })?.error) } catch (e) { console.error('Sync CKB error:', e) alert('同步失败') @@ -190,8 +238,60 @@ export function UserDetailModal({ } } - const removeTag = (tag: string) => { - setEditTags(editTags.filter((t) => t !== tag)) + const removeTag = (tag: string) => setEditTags(editTags.filter((t) => t !== tag)) + + // 神射手查询 + async function handleSSSQuery() { + if (!sssQueryPhone && !user?.openId) { + setSssError('请输入手机号或确保用户已绑定 openId') + return + } + setSssLoading(true) + setSssError(null) + setSssData(null) + try { + const params = new URLSearchParams() + if (sssQueryPhone) params.set('phone', sssQueryPhone) + if (user?.openId) params.set('openId', user.openId) + const data = await get<{ success?: boolean; data?: ShensheShouData; error?: string }>( + `/api/admin/shensheshou/query?${params}`, + ) + if (data?.success && data.data) setSssData(data.data) + else setSssError(data?.error || '未查询到数据,可能该用户未在神射手中收录') + } catch (e) { + console.error('SSS query error:', e) + setSssError('请求失败,请检查神射手接口配置') + } finally { + setSssLoading(false) + } + } + + // 神射手 - 将当前用户信息推送/同步到神射手 + async function handleSSSIngest() { + if (!user) return + setBatchIngestLoading(true) + setBatchIngestResult(null) + try { + const payload = { + users: [{ + phone: user.phone || '', + name: user.nickname || '', + openId: user.openId || '', + tags: editTags, + }] + } + const data = await post<{ success?: boolean; data?: Record; error?: string }>( + '/api/admin/shensheshou/ingest', + payload, + ) + if (data?.success && data.data) setBatchIngestResult(data.data) + else setBatchIngestResult({ error: data?.error || '推送失败' }) + } catch (e) { + console.error('SSS ingest error:', e) + setBatchIngestResult({ error: '请求失败' }) + } finally { + setBatchIngestLoading(false) + } } const getActionIcon = (action: string) => { @@ -204,11 +304,24 @@ export function UserDetailModal({ share: Link2, bind_phone: Phone, bind_wechat: MessageCircle, + fill_profile: Tag, + visit_page: Navigation, } - const Icon = icons[action] || History + const Icon = icons[action] || Clock return } + const getRFMLevelColor = (level: string) => { + const map: Record = { + S: 'bg-amber-500/20 text-amber-400', + A: 'bg-green-500/20 text-green-400', + B: 'bg-blue-500/20 text-blue-400', + C: 'bg-gray-500/20 text-gray-400', + D: 'bg-red-500/20 text-red-400', + } + return map[level] || 'bg-gray-500/20 text-gray-400' + } + if (!open) return null return ( @@ -218,9 +331,8 @@ export function UserDetailModal({ 用户详情 - {user?.phone && ( - 已绑定手机 - )} + {user?.phone && 已绑定手机} + {user?.isVip && VIP} @@ -230,55 +342,77 @@ export function UserDetailModal({ 加载中... ) : user ? ( -
-
-
+
+ {/* 用户头部信息 */} +
+
{user.avatar ? ( ) : ( user.nickname?.charAt(0) || '?' )}
-
-
+
+

{user.nickname}

- {user.isAdmin && ( - 管理员 - )} - {user.hasFullBook && ( - 全书已购 - )} + {user.isAdmin && 管理员} + {user.hasFullBook && 全书已购} + {user.vipRole && {user.vipRole}}

{user.phone ? `📱 ${user.phone}` : '未绑定手机'} {user.wechatId && ` · 💬 ${user.wechatId}`} + {user.mbti && ` · ${user.mbti}`}

-

- ID: {user.id} · 推广码: {user.referralCode ?? '-'} -

+
+

ID: {user.id.slice(0, 16)}…

+ {user.referralCode && ( +

+ 推广码: + {user.referralCode} +

+ )} +
-
-

¥{(user.earnings || 0).toFixed(2)}

+
+

¥{(user.earnings || 0).toFixed(2)}

累计收益

+ {rfmData && ( +
+ + RFM {rfmData.rfmLevel} 级 · {rfmData.rfmScore}分 + +
+ )}
- - + + 基础信息 - + + + RFM估值 + + 标签体系 - - 行为轨迹 + + + 用户旅程 - + 关系链路 + + + 神射手 + + {/* ===== 基础信息 ===== */}
@@ -300,6 +434,36 @@ export function UserDetailModal({ />
+ {/* 详细信息展示 */} +
+ {user.openId && ( +
+

微信 OpenID

+

{user.openId}

+
+ )} + {user.region && ( +
+ +
+

地区

+

{user.region}

+
+
+ )} + {user.industry && ( +
+

行业

+

{user.industry}

+
+ )} + {user.position && ( +
+

职位

+

{user.position}

+
+ )} +

推荐人数

@@ -318,6 +482,24 @@ export function UserDetailModal({

+ {/* VIP 信息 */} + {user.isVip && ( +
+
+ + VIP 信息 + {user.vipRole || 'VIP'} +
+
+ {user.vipName &&
展示名:{user.vipName}
} + {user.vipProject &&
项目:{user.vipProject}
} + {user.vipContact &&
联系方式:{user.vipContact}
} + {user.vipExpireDate &&
到期时间:{new Date(user.vipExpireDate).toLocaleDateString()}
} +
+ {user.vipBio &&

{user.vipBio}

} +
+ )} + {/* 存客宝同步 */}
@@ -331,13 +513,9 @@ export function UserDetailModal({ className="bg-[#38bdac] hover:bg-[#2da396] text-white" > {syncing ? ( - <> - 同步中... - + <> 同步中... ) : ( - <> - 同步数据 - + <> 同步数据 )}
@@ -360,54 +538,212 @@ export function UserDetailModal({
+ {/* ===== RFM 估值 ===== */} + + {rfmData ? ( + <> +
+
+
+ + 用户 RFM 估值 +
+
+
{rfmData.rfmScore}
+ + {rfmData.rfmLevel} 级用户 + +
+
+
+
+
R
+
{rfmData.recency}
+
最近购买天数
+
越低越好
+
+
+
F
+
{rfmData.frequency}
+
购买频次
+
越高越好
+
+
+
M
+
¥{rfmData.monetary.toFixed(0)}
+
消费金额
+
越高越好
+
+
+ {rfmData.lastOrderAt && ( +
+ 最近订单:{new Date(rfmData.lastOrderAt).toLocaleString()} +
+ )} +
+
+

等级说明

+
+ {[ + { level: 'S', label: '超级VIP', desc: '高频高额近期活跃,顶级价值用户', color: 'text-amber-400' }, + { level: 'A', label: '高价值', desc: '消费能力强,复购率高', color: 'text-green-400' }, + { level: 'B', label: '潜力用户', desc: '有一定消费,需重点维护', color: 'text-blue-400' }, + { level: 'C', label: '普通用户', desc: '偶发购买,待激活', color: 'text-gray-400' }, + { level: 'D', label: '低活跃', desc: '长期未购买或无消费记录', color: 'text-red-400' }, + ].map((item) => ( +
+ + {item.level} + + {item.label} + {item.desc} + {rfmData.rfmLevel === item.level && ( + + )} +
+ ))} +
+
+ + ) : ( +
+ +

暂无 RFM 数据

+

用户需有至少一次购买记录才会生成 RFM 估值

+ +
+ )} +
+ + {/* ===== 标签体系 ===== */}
- 系统标签 + 用户标签 + 基于《一场 Soul 的创业实验》维度打标
-
- {editTags.map((tag, i) => ( - - {tag} - - + {/* 预设标签分类 */} +
+ {[ + { + category: '身份类型', + tags: ['创业者', '打工人', '自由职业', '学生', '投资人', '合伙人'], + }, + { + category: '行业背景', + tags: ['电商', '内容', '传统行业', '科技/AI', '金融', '教育', '餐饮'], + }, + { + category: '痛点标签', + tags: ['找资源', '找方向', '找合伙人', '想赚钱', '想学习', '找情感出口'], + }, + { + category: '付费意愿', + tags: ['高意向', '已付费', '观望中', '薅羊毛'], + }, + { + category: 'MBTI', + tags: ['ENTJ', 'INTJ', 'ENFP', 'INFP', 'ENTP', 'INTP', 'ESTJ', 'ISFJ'], + }, + ].map((group) => ( +
+

{group.category}

+
+ {group.tags.map((tag) => ( + + ))} +
+
))} - {editTags.length === 0 && 暂无标签}
-
- setNewTag(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addTag()} - /> - +
+

已选标签

+
+ {editTags.map((tag, i) => ( + + {tag} + + + ))} + {editTags.length === 0 && 暂未选择标签} +
+
+ setNewTag(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addTag()} + /> + +
+ {/* 存客宝标签 */} + {user.ckbTags && ( +
+
+ + 存客宝标签 +
+
+ {(typeof user.ckbTags === 'string' ? user.ckbTags.split(',') : []).map((tag, i) => ( + {tag.trim()} + ))} +
+
+ )} - + {/* ===== 用户旅程(原行为轨迹)===== */} + +
+ + 记录用户从注册到付费的完整行动路径,共 {tracks.length} 条记录 +
{tracks.length > 0 ? ( - tracks.map((track) => ( + tracks.map((track, idx) => (
-
- {getActionIcon(track.action)} +
+
+ {getActionIcon(track.action)} +
+ {idx < tracks.length - 1 && ( +
+ )}
-
+
{track.actionLabel} {track.chapterTitle && ( - {track.chapterTitle} )}
-

+

{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}

@@ -416,13 +752,15 @@ export function UserDetailModal({ )) ) : (
- -

暂无行为轨迹

+ +

暂无用户旅程记录

+

当用户浏览章节、购买或完善信息时会自动记录

)}
+ {/* ===== 关系链路 ===== */}
@@ -432,7 +770,7 @@ export function UserDetailModal({
共 {referrals.length} 人
-
+
{referrals.length > 0 ? ( referrals.map((ref: unknown, i: number) => { const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string } @@ -461,9 +799,119 @@ export function UserDetailModal({
+ + {/* ===== 神射手 ===== */} + +
+
+ + 神射手数据查询 + 通过手机号 / OpenID 查询用户画像 +
+
+ setSssQueryPhone(e.target.value)} + /> + +
+ {sssError && ( +
+ {sssError} +
+ )} + {sssData && ( +
+
+
+

神射手 RFM 分

+

{sssData.rfm_score ?? '-'}

+
+
+

用户等级

+

{sssData.user_level ?? '-'}

+
+
+ {sssData.tags && sssData.tags.length > 0 && ( +
+

神射手标签

+
+ {sssData.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+ )} + {sssData.last_active && ( +
+ 最近活跃:{sssData.last_active} +
+ )} +
+ )} +
+ + {/* 推送到神射手 */} +
+
+
+
+ + 推送用户数据到神射手 +
+

将本用户信息(手机号、昵称、标签等)同步至神射手,自动完善用户画像

+
+ +
+ {!user.phone && ( +

⚠ 用户未绑定手机号,无法推送

+ )} + {batchIngestResult && ( +
+ {batchIngestResult.error ? ( +

{String(batchIngestResult.error)}

+ ) : ( +
+

+ 推送成功 +

+ {batchIngestResult.enriched !== undefined && ( +

自动补全标签数:{String(batchIngestResult.new_tags_added ?? 0)}

+ )} +
+ )} +
+ )} +
+
-
+