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

1054 lines
48 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 toast from '@/utils/toast'
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 { Switch } from '@/components/ui/switch'
import {
User,
Phone,
MapPin,
RefreshCw,
Link2,
BookOpen,
ShoppingBag,
Users,
MessageCircle,
Clock,
Save,
X,
Tag,
Zap,
Search,
CheckCircle2,
Crown,
Key,
Navigation,
Smartphone,
} 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 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 [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 [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
// 设成超级个体VIP
const [vipForm, setVipForm] = useState({ isVip: false, vipExpireDate: '', vipRole: '', vipName: '', vipProject: '', vipContact: '', vipBio: '' })
const [vipRoles, setVipRoles] = useState<{ id: number; name: string }[]>([])
const [vipSaving, setVipSaving] = useState(false)
// 用户资料完善(神射手)
const [sssLoading, setSssLoading] = useState(false)
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
const [sssError, setSssError] = useState<string | null>(null)
const [sssQueryPhone, setSssQueryPhone] = useState('')
const [sssQueryWechatId, setSssQueryWechatId] = useState('')
const [sssQueryOpenId, setSssQueryOpenId] = useState('')
const [batchIngestLoading, setBatchIngestLoading] = useState(false)
const [batchIngestResult, setBatchIngestResult] = useState<Record<string, unknown> | null>(null)
const [ckbWechatOwner, setCkbWechatOwner] = useState('')
useEffect(() => {
if (open && userId) {
setActiveTab('info')
setSssData(null)
setSssError(null)
setBatchIngestResult(null)
setCkbWechatOwner('')
setNewPassword('')
setConfirmPassword('')
loadUserDetail()
get<{ success?: boolean; data?: { id: number; name: string }[] }>('/api/db/vip-roles').then((r) => {
if (r?.success && (r as { data?: { id: number; name: string }[] }).data) setVipRoles((r as { data: { id: number; name: string }[] }).data)
}).catch(() => {})
}
}, [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 || '')
setSssQueryWechatId(u.wechatId || '')
setSssQueryOpenId(u.openId || '')
try {
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
} catch {
setEditTags([])
}
setVipForm({
isVip: !!(u.isVip ?? false),
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
vipRole: String(u.vipRole ?? ''),
vipName: String(u.vipName ?? ''),
vipProject: String(u.vipProject ?? ''),
vipContact: String(u.vipContact ?? ''),
vipBio: String(u.vipBio ?? ''),
})
}
// 行为轨迹(用户旅程)
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([]) }
} catch (e) {
console.error('Load user detail error:', e)
} finally {
setLoading(false)
}
}
async function handleSyncCKB() {
if (!user?.phone) { toast.info('用户未绑定手机号,无法同步'); 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) { toast.success('同步成功'); loadUserDetail() }
else toast.error('同步失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Sync CKB error:', e)
toast.error('同步失败')
} 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) {
toast.success('保存成功')
loadUserDetail()
onUserUpdated?.()
} else {
toast.error('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save user error:', e)
toast.error('保存失败')
} 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 handleSavePassword() {
if (!user) return
if (!newPassword) { toast.error('请输入新密码'); return }
if (newPassword !== confirmPassword) { toast.error('两次密码不一致'); return }
if (newPassword.length < 6) { toast.error('密码至少 6 位'); return }
setPasswordSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, password: newPassword })
if (data?.success) { toast.success('修改成功'); setNewPassword(''); setConfirmPassword('') }
else toast.error('修改失败: ' + (data?.error || ''))
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
}
async function handleSaveVip() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { toast.error('开启 VIP 请填写有效到期日'); return }
setVipSaving(true)
try {
const payload = {
id: user.id,
isVip: vipForm.isVip,
vipExpireDate: vipForm.isVip ? vipForm.vipExpireDate : undefined,
vipRole: vipForm.vipRole || undefined,
vipName: vipForm.vipName || undefined,
vipProject: vipForm.vipProject || undefined,
vipContact: vipForm.vipContact || undefined,
vipBio: vipForm.vipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) { toast.success('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else toast.error('保存失败: ' + (data?.error || ''))
} catch { toast.error('保存失败') } finally { setVipSaving(false) }
}
// 用户资料完善查询(支持多维度)
async function handleSSSQuery() {
if (!sssQueryPhone && !sssQueryOpenId && !sssQueryWechatId) {
setSssError('请至少输入手机号、微信号或 OpenID 中的一项')
return
}
setSssLoading(true)
setSssError(null)
setSssData(null)
try {
const params = new URLSearchParams()
if (sssQueryPhone) params.set('phone', sssQueryPhone)
if (sssQueryOpenId) params.set('openId', sssQueryOpenId)
if (sssQueryWechatId) params.set('wechatId', sssQueryWechatId)
const data = await get<{ success?: boolean; data?: ShensheShouData; error?: string }>(
`/api/admin/shensheshou/query?${params}`,
)
if (data?.success && data.data) {
setSssData(data.data)
// 自动回填到用户信息
if (user) await handleSSSEnrich(data.data)
}
else setSssError(data?.error || '未查询到数据,该用户可能未在神射手收录')
} catch (e) {
console.error('SSS query error:', e)
setSssError('请求失败,请检查神射手接口配置')
} finally {
setSssLoading(false)
}
}
// 查询后自动回填用户基础信息
async function handleSSSEnrich(_sssResult?: ShensheShouData) {
if (!user) return
try {
await post('/api/admin/shensheshou/enrich', {
userId: user.id,
phone: sssQueryPhone || user.phone || '',
openId: sssQueryOpenId || user.openId || '',
wechatId: sssQueryWechatId || user.wechatId || '',
})
loadUserDetail()
} catch (e) {
console.error('SSS enrich error:', e)
}
}
// 神射手 - 将当前用户信息推送/同步到神射手
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" />
}
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>
</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="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>
{/* 快捷操作:修改密码 & 设成超级个体 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg border border-gray-700/50">
<div className="flex items-center gap-2 mb-3">
<Key className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium"></span>
</div>
<div className="space-y-2">
<Input
type="password"
className="bg-[#162840] border-gray-700 text-white"
placeholder="新密码至少6位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
type="password"
className="bg-[#162840] border-gray-700 text-white"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button
size="sm"
onClick={handleSavePassword}
disabled={passwordSaving || !newPassword || !confirmPassword}
className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/40"
>
{passwordSaving ? '保存中...' : '确认修改'}
</Button>
</div>
</div>
<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"></span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-gray-400 text-sm">VIP </Label>
<Switch
checked={vipForm.isVip}
onCheckedChange={(c) => setVipForm((f) => ({ ...f, isVip: c }))}
/>
</div>
{vipForm.isVip && (
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="date"
className="bg-[#162840] border-gray-700 text-white text-sm"
value={vipForm.vipExpireDate}
onChange={(e) => setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<select
className="w-full bg-[#162840] border border-gray-700 text-white rounded px-2 py-1.5 text-sm"
value={vipForm.vipRole}
onChange={(e) => setVipForm((f) => ({ ...f, vipRole: e.target.value }))}
>
<option value=""></option>
{vipRoles.map((r) => (
<option key={r.id} value={r.name}>{r.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white text-sm"
placeholder="创业老板排行展示名"
value={vipForm.vipName}
onChange={(e) => setVipForm((f) => ({ ...f, vipName: e.target.value }))}
/>
</div>
<Button
size="sm"
onClick={handleSaveVip}
disabled={vipSaving}
className="bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 border border-amber-500/40"
>
{vipSaving ? '保存中...' : '保存 VIP'}
</Button>
</div>
</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 border border-purple-500/20">
<div className="flex items-center gap-2 mb-3">
<Smartphone className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium"></span>
<span className="text-gray-500 text-xs"></span>
</div>
<div className="flex gap-2 items-center">
<Input
className="bg-[#162840] border-gray-700 text-white flex-1"
placeholder="输入归属微信号(如 wxid_xxxx"
value={ckbWechatOwner}
onChange={(e) => setCkbWechatOwner(e.target.value)}
/>
<Button
size="sm"
onClick={async () => {
if (!ckbWechatOwner || !user) return
try {
await put('/api/db/users', { id: user.id, wechatId: ckbWechatOwner })
toast.success('已保存微信归属')
loadUserDetail()
} catch { toast.error('保存失败') }
}}
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 border border-purple-500/30 shrink-0"
>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
{user.wechatId && (
<p className="text-gray-500 text-xs mt-2"><span className="text-purple-400">{user.wechatId}</span></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>
{/* ===== 标签体系 ===== */}
<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-3 p-2.5 bg-[#38bdac]/5 border border-[#38bdac]/20 rounded-lg flex items-center gap-2 text-xs text-gray-400">
<CheckCircle2 className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
· ·
</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"></span>
</div>
{/* 多维度查询输入 */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div>
<Label className="text-gray-500 text-xs mb-1 block"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="11位手机号"
value={sssQueryPhone}
onChange={(e) => setSssQueryPhone(e.target.value)}
/>
</div>
<div>
<Label className="text-gray-500 text-xs mb-1 block"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="微信 ID"
value={sssQueryWechatId}
onChange={(e) => setSssQueryWechatId(e.target.value)}
/>
</div>
<div className="col-span-2">
<Label className="text-gray-500 text-xs mb-1 block"> OpenID</Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="openid_xxxx自动填入"
value={sssQueryOpenId}
onChange={(e) => setSssQueryOpenId(e.target.value)}
/>
</div>
</div>
<Button
onClick={handleSSSQuery}
disabled={sssLoading}
className="w-full 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>
<p className="text-gray-600 text-xs mt-2"></p>
{sssError && (
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{sssError}
</div>
)}
{sssData && (
<div className="mt-3 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>
)
}