Files
Mycontent/soul-admin/src/components/modules/user/UserDetailModal.tsx

936 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
User,
Phone,
MapPin,
RefreshCw,
Link2,
BookOpen,
ShoppingBag,
Users,
MessageCircle,
Clock,
Save,
X,
Tag,
TrendingUp,
Zap,
Search,
CheckCircle2,
Crown,
Navigation,
} from 'lucide-react'
import { get, put, post } from '@/api/client'
interface UserDetailModalProps {
open: boolean
onClose: () => void
userId: string | null
onUserUpdated?: () => void
}
interface UserDetail {
id: string
phone?: string
nickname: string
avatar?: string
wechatId?: string
openId?: string
referralCode?: string
referredBy?: string
hasFullBook?: boolean
isAdmin?: boolean
earnings?: number
pendingEarnings?: number
referralCount?: number
createdAt?: string
updatedAt?: string
tags?: string
ckbTags?: string
ckbSyncedAt?: string
isVip?: boolean
vipExpireDate?: string | null
vipName?: string | null
vipAvatar?: string | null
vipProject?: string | null
vipContact?: string | null
vipBio?: string | null
vipRole?: string | null
// 扩展字段
mbti?: string
region?: string
industry?: string
position?: string
}
interface UserTrack {
id: string
action: string
actionLabel: string
target?: string
chapterTitle?: string
createdAt: string
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,
userId,
onUserUpdated,
}: UserDetailModalProps) {
const [user, setUser] = useState<UserDetail | null>(null)
const [tracks, setTracks] = useState<UserTrack[]>([])
const [referrals, setReferrals] = useState<unknown[]>([])
const [rfmData, setRfmData] = useState<RFMData | null>(null)
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState('info')
const [editPhone, setEditPhone] = useState('')
const [editNickname, setEditNickname] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
// 神射手
const [sssLoading, setSssLoading] = useState(false)
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
const [sssError, setSssError] = useState<string | null>(null)
const [sssQueryPhone, setSssQueryPhone] = useState('')
const [batchIngestLoading, setBatchIngestLoading] = useState(false)
const [batchIngestResult, setBatchIngestResult] = useState<Record<string, unknown> | null>(null)
useEffect(() => {
if (open && userId) {
setActiveTab('info')
setSssData(null)
setSssError(null)
setBatchIngestResult(null)
setRfmData(null)
loadUserDetail()
}
}, [open, userId])
async function loadUserDetail() {
if (!userId) return
setLoading(true)
try {
const userData = await get<{ success?: boolean; user?: UserDetail }>(
`/api/db/users?id=${encodeURIComponent(userId)}`,
)
if (userData?.success && userData.user) {
const u = userData.user
setUser(u)
setEditPhone(u.phone || '')
setEditNickname(u.nickname || '')
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([]) }
// 关系链路
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([]) }
// 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 {
setLoading(false)
}
}
async function handleSyncCKB() {
if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return }
setSyncing(true)
try {
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
action: 'full_sync',
phone: user.phone,
userId: user.id,
})
if (data?.success) { alert('同步成功'); loadUserDetail() }
else alert('同步失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Sync CKB error:', e)
alert('同步失败')
} finally {
setSyncing(false)
}
}
async function handleSave() {
if (!user) return
setSaving(true)
try {
const payload: Record<string, unknown> = {
id: user.id,
phone: editPhone || undefined,
nickname: editNickname || undefined,
tags: JSON.stringify(editTags),
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('保存成功')
loadUserDetail()
onUserUpdated?.()
} else {
alert('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save user error:', e)
alert('保存失败')
} finally {
setSaving(false)
}
}
const addTag = () => {
if (newTag && !editTags.includes(newTag)) {
setEditTags([...editTags, newTag])
setNewTag('')
}
}
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<string, unknown>; 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) => {
const icons: Record<string, React.ComponentType<{ className?: string }>> = {
view_chapter: BookOpen,
purchase: ShoppingBag,
match: Users,
login: User,
register: User,
share: Link2,
bind_phone: Phone,
bind_wechat: MessageCircle,
fill_profile: Tag,
visit_page: Navigation,
}
const Icon = icons[action] || Clock
return <Icon className="w-4 h-4" />
}
const getRFMLevelColor = (level: string) => {
const map: Record<string, string> = {
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 (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
{user?.phone && <Badge className="bg-green-500/20 text-green-400 border-0 ml-2"></Badge>}
{user?.isVip && <Badge className="bg-amber-500/20 text-amber-400 border-0">VIP</Badge>}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-20">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : user ? (
<div className="flex flex-col h-[75vh]">
{/* 用户头部信息 */}
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-3">
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac] shrink-0">
{user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
user.nickname?.charAt(0) || '?'
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 border-0"></Badge>}
{user.hasFullBook && <Badge className="bg-green-500/20 text-green-400 border-0"></Badge>}
{user.vipRole && <Badge className="bg-amber-500/20 text-amber-400 border-0">{user.vipRole}</Badge>}
</div>
<p className="text-gray-400 text-sm mt-1">
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
{user.wechatId && ` · 💬 ${user.wechatId}`}
{user.mbti && ` · ${user.mbti}`}
</p>
<div className="flex items-center gap-4 mt-1">
<p className="text-gray-600 text-xs">ID: {user.id.slice(0, 16)}</p>
{user.referralCode && (
<p className="text-xs">
<span className="text-gray-500">广</span>
<code className="text-[#38bdac] bg-[#38bdac]/10 px-1.5 py-0.5 rounded">{user.referralCode}</code>
</p>
)}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-[#38bdac] font-bold text-lg">¥{(user.earnings || 0).toFixed(2)}</p>
<p className="text-gray-500 text-xs"></p>
{rfmData && (
<div className="mt-1">
<Badge className={`border-0 text-xs ${getRFMLevelColor(rfmData.rfmLevel)}`}>
RFM {rfmData.rfmLevel} · {rfmData.rfmScore}
</Badge>
</div>
)}
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-3 flex-wrap h-auto gap-1">
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
</TabsTrigger>
<TabsTrigger value="rfm" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
<TrendingUp className="w-3 h-3 mr-1" />
RFM估值
</TabsTrigger>
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
</TabsTrigger>
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
<Navigation className="w-3 h-3 mr-1" />
</TabsTrigger>
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
</TabsTrigger>
<TabsTrigger value="shensheshou" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
<Zap className="w-3 h-3 mr-1" />
</TabsTrigger>
</TabsList>
{/* ===== 基础信息 ===== */}
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="输入手机号"
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="输入昵称"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
/>
</div>
</div>
{/* 详细信息展示 */}
<div className="grid grid-cols-2 gap-3 text-sm">
{user.openId && (
<div className="p-3 bg-[#0a1628] rounded-lg">
<p className="text-gray-500 text-xs mb-1"> OpenID</p>
<p className="text-gray-300 font-mono text-xs break-all">{user.openId}</p>
</div>
)}
{user.region && (
<div className="p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-500" />
<div>
<p className="text-gray-500 text-xs"></p>
<p className="text-white">{user.region}</p>
</div>
</div>
)}
{user.industry && (
<div className="p-3 bg-[#0a1628] rounded-lg">
<p className="text-gray-500 text-xs mb-1"></p>
<p className="text-white">{user.industry}</p>
</div>
)}
{user.position && (
<div className="p-3 bg-[#0a1628] rounded-lg">
<p className="text-gray-500 text-xs mb-1"></p>
<p className="text-white">{user.position}</p>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white">{user.referralCount ?? 0}</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-yellow-400">
¥{(user.pendingEarnings ?? 0).toFixed(2)}
</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-sm text-white">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
</p>
</div>
</div>
{/* VIP 信息 */}
{user.isVip && (
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">VIP </span>
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">{user.vipRole || 'VIP'}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
{user.vipName && <div><span className="text-gray-500"></span><span className="text-white">{user.vipName}</span></div>}
{user.vipProject && <div><span className="text-gray-500"></span><span className="text-white">{user.vipProject}</span></div>}
{user.vipContact && <div><span className="text-gray-500"></span><span className="text-white">{user.vipContact}</span></div>}
{user.vipExpireDate && <div><span className="text-gray-500"></span><span className="text-white">{new Date(user.vipExpireDate).toLocaleDateString()}</span></div>}
</div>
{user.vipBio && <p className="text-gray-400 text-sm mt-2">{user.vipBio}</p>}
</div>
)}
{/* 存客宝同步 */}
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Link2 className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium"></span>
</div>
<Button
size="sm"
onClick={handleSyncCKB}
disabled={syncing || !user.phone}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{syncing ? (
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> ...</>
) : (
<><RefreshCw className="w-4 h-4 mr-1" /> </>
)}
</Button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
{user.ckbSyncedAt ? (
<Badge className="bg-green-500/20 text-green-400 border-0 ml-1"></Badge>
) : (
<Badge className="bg-gray-500/20 text-gray-400 border-0 ml-1"></Badge>
)}
</div>
<div>
<span className="text-gray-500"></span>
<span className="text-gray-300 ml-1">
{user.ckbSyncedAt ? new Date(user.ckbSyncedAt).toLocaleString() : '-'}
</span>
</div>
</div>
</div>
</TabsContent>
{/* ===== RFM 估值 ===== */}
<TabsContent value="rfm" className="flex-1 overflow-auto space-y-4">
{rfmData ? (
<>
<div className="p-4 bg-[#0a1628] rounded-lg border border-gray-700/30">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
<span className="text-white font-medium text-lg"> RFM </span>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-white">{rfmData.rfmScore}</div>
<Badge className={`border-0 ${getRFMLevelColor(rfmData.rfmLevel)}`}>
{rfmData.rfmLevel}
</Badge>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 bg-[#162840] rounded-lg text-center">
<div className="w-8 h-8 rounded bg-red-500/20 flex items-center justify-center text-sm font-bold text-red-400 mx-auto mb-2">R</div>
<div className="text-2xl font-bold text-red-400">{rfmData.recency}</div>
<div className="text-gray-500 text-xs mt-1"></div>
<div className="text-gray-600 text-xs"></div>
</div>
<div className="p-3 bg-[#162840] rounded-lg text-center">
<div className="w-8 h-8 rounded bg-blue-500/20 flex items-center justify-center text-sm font-bold text-blue-400 mx-auto mb-2">F</div>
<div className="text-2xl font-bold text-blue-400">{rfmData.frequency}</div>
<div className="text-gray-500 text-xs mt-1"></div>
<div className="text-gray-600 text-xs"></div>
</div>
<div className="p-3 bg-[#162840] rounded-lg text-center">
<div className="w-8 h-8 rounded bg-green-500/20 flex items-center justify-center text-sm font-bold text-green-400 mx-auto mb-2">M</div>
<div className="text-2xl font-bold text-green-400">¥{rfmData.monetary.toFixed(0)}</div>
<div className="text-gray-500 text-xs mt-1"></div>
<div className="text-gray-600 text-xs"></div>
</div>
</div>
{rfmData.lastOrderAt && (
<div className="mt-3 text-sm text-gray-500">
{new Date(rfmData.lastOrderAt).toLocaleString()}
</div>
)}
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm mb-3 font-medium"></p>
<div className="space-y-2 text-sm">
{[
{ 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) => (
<div key={item.level} className={`flex items-center gap-3 ${rfmData.rfmLevel === item.level ? 'opacity-100' : 'opacity-50'}`}>
<Badge className={`border-0 w-6 h-6 flex items-center justify-center p-0 ${getRFMLevelColor(item.level)}`}>
{item.level}
</Badge>
<span className={`font-medium w-16 ${item.color}`}>{item.label}</span>
<span className="text-gray-500">{item.desc}</span>
{rfmData.rfmLevel === item.level && (
<CheckCircle2 className="w-4 h-4 text-[#38bdac] ml-auto" />
)}
</div>
))}
</div>
</div>
</>
) : (
<div className="text-center py-12">
<TrendingUp className="w-12 h-12 text-[#38bdac]/30 mx-auto mb-4" />
<p className="text-gray-400"> RFM </p>
<p className="text-gray-600 text-sm mt-1"> RFM </p>
<Button
className="mt-4 bg-[#38bdac] hover:bg-[#2da396] text-white"
onClick={loadUserDetail}
>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</TabsContent>
{/* ===== 标签体系 ===== */}
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Tag className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium"></span>
<span className="text-gray-500 text-xs"> Soul </span>
</div>
{/* 预设标签分类 */}
<div className="mb-4 space-y-3">
{[
{
category: '身份类型',
tags: ['创业者', '打工人', '自由职业', '学生', '投资人', '合伙人'],
},
{
category: '行业背景',
tags: ['电商', '内容', '传统行业', '科技/AI', '金融', '教育', '餐饮'],
},
{
category: '痛点标签',
tags: ['找资源', '找方向', '找合伙人', '想赚钱', '想学习', '找情感出口'],
},
{
category: '付费意愿',
tags: ['高意向', '已付费', '观望中', '薅羊毛'],
},
{
category: 'MBTI',
tags: ['ENTJ', 'INTJ', 'ENFP', 'INFP', 'ENTP', 'INTP', 'ESTJ', 'ISFJ'],
},
].map((group) => (
<div key={group.category}>
<p className="text-gray-500 text-xs mb-1.5">{group.category}</p>
<div className="flex flex-wrap gap-1.5">
{group.tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => {
if (editTags.includes(tag)) removeTag(tag)
else setEditTags([...editTags, tag])
}}
className={`px-2 py-0.5 rounded text-xs border transition-all ${
editTags.includes(tag)
? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]'
: 'bg-transparent border-gray-700 text-gray-500 hover:border-gray-500 hover:text-gray-300'
}`}
>
{editTags.includes(tag) ? '✓ ' : ''}{tag}
</button>
))}
</div>
</div>
))}
</div>
<div className="border-t border-gray-700/50 pt-3">
<p className="text-gray-500 text-xs mb-2"></p>
<div className="flex flex-wrap gap-2 mb-3 min-h-[32px]">
{editTags.map((tag, i) => (
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
<X className="w-3 h-3" />
</button>
</Badge>
))}
{editTags.length === 0 && <span className="text-gray-600 text-sm"></span>}
</div>
<div className="flex gap-2">
<Input
className="bg-[#162840] border-gray-700 text-white flex-1"
placeholder="自定义标签(回车添加)"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTag()}
/>
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]"></Button>
</div>
</div>
</div>
{/* 存客宝标签 */}
{user.ckbTags && (
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Tag className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium"></span>
</div>
<div className="flex flex-wrap gap-2">
{(typeof user.ckbTags === 'string' ? user.ckbTags.split(',') : []).map((tag, i) => (
<Badge key={i} className="bg-purple-500/20 text-purple-400 border-0">{tag.trim()}</Badge>
))}
</div>
</div>
)}
</TabsContent>
{/* ===== 用户旅程(原行为轨迹)===== */}
<TabsContent value="journey" className="flex-1 overflow-auto">
<div className="mb-3 p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
<Navigation className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-400 text-sm"> {tracks.length} </span>
</div>
<div className="space-y-2">
{tracks.length > 0 ? (
tracks.map((track, idx) => (
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
{getActionIcon(track.action)}
</div>
{idx < tracks.length - 1 && (
<div className="w-0.5 h-4 bg-gray-700/50 mt-1" />
)}
</div>
<div className="flex-1 pb-1">
<div className="flex items-center gap-2">
<span className="text-white font-medium">{track.actionLabel}</span>
{track.chapterTitle && (
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
)}
</div>
<p className="text-gray-500 text-xs mt-0.5">
<Clock className="w-3 h-3 inline mr-1" />
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
</p>
</div>
</div>
))
) : (
<div className="text-center py-12">
<Navigation className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
<p className="text-gray-400"></p>
<p className="text-gray-600 text-sm mt-1"></p>
</div>
)}
</div>
</TabsContent>
{/* ===== 关系链路 ===== */}
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Link2 className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium"></span>
</div>
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0"> {referrals.length} </Badge>
</div>
<div className="space-y-2 max-h-[250px] overflow-y-auto">
{referrals.length > 0 ? (
referrals.map((ref: unknown, i: number) => {
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
return (
<div key={r.id || i} className="flex items-center justify-between p-2 bg-[#162840] rounded">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-xs text-[#38bdac]">
{r.nickname?.charAt(0) || '?'}
</div>
<span className="text-white text-sm">{r.nickname}</span>
</div>
<div className="flex items-center gap-2">
{r.status === 'vip' && (
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs"></Badge>
)}
<span className="text-gray-500 text-xs">
{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}
</span>
</div>
</div>
)
})
) : (
<p className="text-gray-500 text-sm text-center py-4"></p>
)}
</div>
</div>
</TabsContent>
{/* ===== 神射手 ===== */}
<TabsContent value="shensheshou" className="flex-1 overflow-auto space-y-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-5 h-5 text-[#38bdac]" />
<span className="text-white font-medium"></span>
<span className="text-gray-500 text-xs"> / OpenID </span>
</div>
<div className="flex gap-2 mb-4">
<Input
className="bg-[#162840] border-gray-700 text-white flex-1"
placeholder="手机号(自动填入已绑定手机)"
value={sssQueryPhone}
onChange={(e) => setSssQueryPhone(e.target.value)}
/>
<Button
onClick={handleSSSQuery}
disabled={sssLoading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{sssLoading ? (
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> </>
) : (
<><Search className="w-4 h-4 mr-1" /> </>
)}
</Button>
</div>
{sssError && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm mb-3">
{sssError}
</div>
)}
{sssData && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-[#162840] rounded-lg">
<p className="text-gray-500 text-xs mb-1"> RFM </p>
<p className="text-2xl font-bold text-[#38bdac]">{sssData.rfm_score ?? '-'}</p>
</div>
<div className="p-3 bg-[#162840] rounded-lg">
<p className="text-gray-500 text-xs mb-1"></p>
<p className="text-2xl font-bold text-white">{sssData.user_level ?? '-'}</p>
</div>
</div>
{sssData.tags && sssData.tags.length > 0 && (
<div className="p-3 bg-[#162840] rounded-lg">
<p className="text-gray-500 text-xs mb-2"></p>
<div className="flex flex-wrap gap-2">
{sssData.tags.map((tag, i) => (
<Badge key={i} className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/20">
{tag}
</Badge>
))}
</div>
</div>
)}
{sssData.last_active && (
<div className="text-sm text-gray-500">
{sssData.last_active}
</div>
)}
</div>
)}
</div>
{/* 推送到神射手 */}
<div className="p-4 bg-[#0a1628] rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium"></span>
</div>
<p className="text-gray-500 text-xs"></p>
</div>
<Button
onClick={handleSSSIngest}
disabled={batchIngestLoading || !user.phone}
variant="outline"
className="border-purple-500/40 text-purple-400 hover:bg-purple-500/10 bg-transparent shrink-0 ml-4"
>
{batchIngestLoading ? (
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> </>
) : (
<><Zap className="w-4 h-4 mr-1" /> </>
)}
</Button>
</div>
{!user.phone && (
<p className="text-yellow-500/70 text-xs"> </p>
)}
{batchIngestResult && (
<div className="mt-3 p-3 bg-[#162840] rounded-lg text-sm">
{batchIngestResult.error ? (
<p className="text-red-400">{String(batchIngestResult.error)}</p>
) : (
<div className="space-y-1">
<p className="text-green-400 flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" />
</p>
{batchIngestResult.enriched !== undefined && (
<p className="text-gray-400">{String(batchIngestResult.new_tags_added ?? 0)}</p>
)}
</div>
)}
</div>
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3">
<Button
variant="outline"
onClick={onClose}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave} disabled={saving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存修改'}
</Button>
</div>
</div>
) : (
<div className="text-center py-12 text-gray-500"></div>
)}
</DialogContent>
</Dialog>
)
}