更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。

This commit is contained in:
Alex-larget
2026-03-10 11:04:34 +08:00
parent 30ebdb5ac7
commit 05ac60dc7e
60 changed files with 8387 additions and 1583 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
@@ -10,10 +10,11 @@ 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,
History,
MapPin,
RefreshCw,
Link2,
BookOpen,
@@ -24,6 +25,13 @@ import {
Save,
X,
Tag,
Zap,
Search,
CheckCircle2,
Crown,
Key,
Navigation,
Smartphone,
} from 'lucide-react'
import { get, put, post } from '@/api/client'
@@ -60,6 +68,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 +86,14 @@ interface UserTrack {
timeAgo: string
}
interface ShensheShouData {
rfm_score?: number
user_level?: string
tags?: string[]
last_active?: string
phone?: string
}
export function UserDetailModal({
open,
onClose,
@@ -90,8 +112,41 @@ export function UserDetailModal({
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) loadUserDetail()
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() {
@@ -106,24 +161,38 @@ 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 || '')
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([])
}
} 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([]) }
} catch (e) {
console.error('Load user detail error:', e)
} finally {
@@ -132,10 +201,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 +209,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 +252,115 @@ 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 handleSavePassword() {
if (!user) return
if (!newPassword) { alert('请输入新密码'); return }
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
if (newPassword.length < 6) { alert('密码至少 6 位'); return }
setPasswordSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, password: newPassword })
if (data?.success) { alert('修改成功'); setNewPassword(''); setConfirmPassword('') }
else alert('修改失败: ' + (data?.error || ''))
} catch { alert('修改失败') } finally { setPasswordSaving(false) }
}
async function handleSaveVip() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { alert('开启 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) { alert('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else alert('保存失败: ' + (data?.error || ''))
} catch { alert('保存失败') } 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) => {
@@ -204,8 +373,10 @@ 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 <Icon className="w-4 h-4" />
}
@@ -218,9 +389,8 @@ export function UserDetailModal({
<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?.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>
@@ -230,55 +400,66 @@ export function UserDetailModal({
<span className="ml-2 text-gray-400">...</span>
</div>
) : user ? (
<div className="flex flex-col h-[70vh]">
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
<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">
<div className="flex items-center gap-2">
<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.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>
<p className="text-gray-500 text-xs mt-1">
ID: {user.id} · 广: {user.referralCode ?? '-'}
</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">
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
<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-4">
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
<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]">
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
</TabsTrigger>
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
<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]">
<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">
@@ -300,6 +481,36 @@ export function UserDetailModal({
/>
</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>
@@ -318,6 +529,149 @@ export function UserDetailModal({
</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 })
alert('已保存微信归属')
loadUserDetail()
} catch { alert('保存失败') }
}}
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">
@@ -331,13 +685,9 @@ export function UserDetailModal({
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 animate-spin" /> ...</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-1" />
</>
<><RefreshCw className="w-4 h-4 mr-1" /> </>
)}
</Button>
</div>
@@ -360,54 +710,134 @@ export function UserDetailModal({
</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-white font-medium"></span>
<span className="text-gray-500 text-xs"> Soul </span>
</div>
<div className="flex flex-wrap gap-2 mb-3">
{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>
{/* 自动打标说明 */}
<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>
))}
{editTags.length === 0 && <span className="text-gray-500 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 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="tracks" className="flex-1 overflow-auto">
{/* ===== 用户旅程(原行为轨迹)===== */}
<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) => (
tracks.map((track, idx) => (
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
{getActionIcon(track.action)}
<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">
<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-1">
<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>
@@ -416,13 +846,15 @@ export function UserDetailModal({
))
) : (
<div className="text-center py-12">
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
<p className="text-gray-400"></p>
<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">
@@ -432,7 +864,7 @@ export function UserDetailModal({
</div>
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0"> {referrals.length} </Badge>
</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
<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 }
@@ -461,9 +893,142 @@ export function UserDetailModal({
</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-4 border-t border-gray-700 mt-4">
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3">
<Button
variant="outline"
onClick={onClose}