1245 lines
59 KiB
TypeScript
1245 lines
59 KiB
TypeScript
import toast from '@/utils/toast'
|
||
import { normalizeImageUrl } from '@/lib/utils'
|
||
import { useState, useEffect, useMemo } 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,
|
||
} 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
|
||
module?: string
|
||
moduleLabel?: string
|
||
createdAt: string
|
||
timeAgo?: string
|
||
}
|
||
|
||
interface InboundVisitItem {
|
||
seq: number
|
||
visitedAt?: string
|
||
referrerId?: string
|
||
referrerNickname?: string
|
||
referrerAvatar?: string
|
||
source?: string
|
||
page?: string
|
||
}
|
||
|
||
interface InboundSourceData {
|
||
totalVisits?: number
|
||
firstVisit?: InboundVisitItem
|
||
latestVisit?: InboundVisitItem
|
||
activeBinding?: {
|
||
referrerId?: string
|
||
referrerNickname?: string
|
||
referrerAvatar?: string
|
||
referralCode?: string
|
||
bindingDate?: string
|
||
expiryDate?: string
|
||
}
|
||
visits?: InboundVisitItem[]
|
||
}
|
||
|
||
interface ShensheShouData {
|
||
rfm_score?: number
|
||
user_level?: string
|
||
tags?: string[]
|
||
last_active?: string
|
||
phone?: string
|
||
}
|
||
|
||
const TRACK_ACTION_LABELS: Record<string, string> = {
|
||
view_chapter: '浏览章节',
|
||
purchase: '购买',
|
||
match: '派对匹配',
|
||
login: '登录',
|
||
register: '注册',
|
||
share: '分享',
|
||
bind_phone: '绑定手机',
|
||
bind_wechat: '绑定微信',
|
||
fill_profile: '完善资料',
|
||
fill_avatar: '设置头像',
|
||
visit_page: '访问页面',
|
||
first_pay: '首次付款',
|
||
vip_activate: '开通会员',
|
||
click_super: '点击超级个体',
|
||
lead_submit: '提交留资',
|
||
withdraw: '申请提现',
|
||
referral_bind: '绑定推荐人',
|
||
card_click: '点击名片',
|
||
btn_click: '按钮点击',
|
||
tab_click: '切换标签',
|
||
nav_click: '导航点击',
|
||
page_view: '页面浏览',
|
||
search: '搜索',
|
||
}
|
||
|
||
function labelTrackAction(action: string) {
|
||
return TRACK_ACTION_LABELS[action] || action || '行为'
|
||
}
|
||
|
||
/** 根据行为轨迹统计推断运营标签(供标签体系 Tab 展示,可一键合并到已选) */
|
||
function inferTagsFromJourney(trackStats: Record<string, number>, user: UserDetail | null): string[] {
|
||
const set = new Set<string>()
|
||
const has = (a: string) => (trackStats[a] ?? 0) > 0
|
||
if (has('purchase') || has('first_pay') || has('vip_activate')) set.add('已付费')
|
||
if (has('lead_submit') || has('click_super')) set.add('高意向')
|
||
if (has('view_chapter')) set.add('想学习')
|
||
if (has('match')) set.add('找合伙人')
|
||
if (has('withdraw')) set.add('有提现行为')
|
||
if (has('referral_bind')) set.add('推广参与')
|
||
if (has('fill_profile') || has('fill_avatar') || has('bind_phone')) set.add('资料完善中')
|
||
if (user?.hasFullBook) set.add('全书读者')
|
||
if (user?.isVip) set.add('VIP会员')
|
||
if (user?.mbti && /^[EI][NS][FT][JP]$/i.test(user.mbti)) set.add(String(user.mbti).toUpperCase())
|
||
return Array.from(set)
|
||
}
|
||
|
||
export function UserDetailModal({
|
||
open,
|
||
onClose,
|
||
userId,
|
||
onUserUpdated,
|
||
}: UserDetailModalProps) {
|
||
const [user, setUser] = useState<UserDetail | null>(null)
|
||
const [tracks, setTracks] = useState<UserTrack[]>([])
|
||
const [trackStats, setTrackStats] = useState<Record<string, number>>({})
|
||
const [referrals, setReferrals] = useState<unknown[]>([])
|
||
const [inboundSource, setInboundSource] = useState<InboundSourceData | null>(null)
|
||
const [balanceData, setBalanceData] = useState<{ balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } | 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 [editWechatId, setEditWechatId] = 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 [adjustBalanceOpen, setAdjustBalanceOpen] = useState(false)
|
||
const [adjustAmount, setAdjustAmount] = useState('')
|
||
const [adjustRemark, setAdjustRemark] = useState('')
|
||
const [adjustLoading, setAdjustLoading] = 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 [avatarBroken, setAvatarBroken] = useState(false)
|
||
const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState<Record<string, string>>({})
|
||
const [purchaseList, setPurchaseList] = useState<{ orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[]>([])
|
||
|
||
useEffect(() => {
|
||
if (open && userId) {
|
||
setAvatarBroken(false)
|
||
setActiveTab('info')
|
||
setSssData(null)
|
||
setSssError(null)
|
||
setBatchIngestResult(null)
|
||
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])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
|
||
.then((r) => {
|
||
if (r?.avatars && typeof r.avatars === 'object') setMbtiAvatarsMap(r.avatars)
|
||
else setMbtiAvatarsMap({})
|
||
})
|
||
.catch(() => setMbtiAvatarsMap({}))
|
||
}, [open])
|
||
|
||
const resolveAvatarByMbti = (avatar?: string | null, mbti?: string | null): string => {
|
||
const av = (avatar || '').trim()
|
||
if (av) return normalizeImageUrl(av)
|
||
const key = (mbti || '').trim().toUpperCase()
|
||
if (!/^[EI][NS][FT][JP]$/.test(key)) return ''
|
||
return (mbtiAvatarsMap[key] || '').trim()
|
||
}
|
||
|
||
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 || '')
|
||
setEditWechatId(u.wechatId || '')
|
||
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[]; stats?: Record<string, number> }>(
|
||
`/api/admin/user/track?userId=${encodeURIComponent(userId)}&limit=100`,
|
||
)
|
||
if (trackData?.success) {
|
||
setTrackStats(trackData.stats && typeof trackData.stats === 'object' ? trackData.stats : {})
|
||
const list = trackData.tracks || []
|
||
setTracks(
|
||
list.map((t) => ({
|
||
...t,
|
||
actionLabel: t.actionLabel || t.action,
|
||
timeAgo: t.timeAgo || '',
|
||
})),
|
||
)
|
||
} else {
|
||
setTrackStats({})
|
||
setTracks([])
|
||
}
|
||
} catch {
|
||
setTrackStats({})
|
||
setTracks([])
|
||
}
|
||
// 关系链路
|
||
try {
|
||
const refData = await get<{ success?: boolean; referrals?: unknown[]; inboundSource?: InboundSourceData }>(
|
||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||
)
|
||
if (refData?.success) {
|
||
setReferrals(refData.referrals || [])
|
||
setInboundSource(refData.inboundSource || null)
|
||
} else {
|
||
setReferrals([])
|
||
setInboundSource(null)
|
||
}
|
||
} catch {
|
||
setReferrals([])
|
||
setInboundSource(null)
|
||
}
|
||
try {
|
||
const balData = await get<{ success?: boolean; data?: { balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } }>(
|
||
`/api/admin/users/${encodeURIComponent(userId)}/balance`,
|
||
)
|
||
if (balData?.success && balData.data) setBalanceData(balData.data)
|
||
else setBalanceData(null)
|
||
} catch { setBalanceData(null) }
|
||
try {
|
||
const ordersData = await get<{ success?: boolean; orders?: { orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[] }>(
|
||
`/api/orders?userId=${encodeURIComponent(userId)}&status=paid&pageSize=50`,
|
||
)
|
||
if (ordersData?.success && ordersData.orders) setPurchaseList(ordersData.orders)
|
||
else setPurchaseList([])
|
||
} catch { setPurchaseList([]) }
|
||
} 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
|
||
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) {
|
||
toast.error('开启 VIP 请填写有效到期日')
|
||
return
|
||
}
|
||
setSaving(true)
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
id: user.id,
|
||
phone: editPhone.trim() || undefined,
|
||
wechatId: editWechatId.trim(),
|
||
nickname: editNickname || undefined,
|
||
tags: JSON.stringify(editTags),
|
||
// 超级个体/VIP 相关字段一并保存
|
||
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('保存成功')
|
||
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 handleAdjustBalance() {
|
||
if (!user) return
|
||
const amt = parseFloat(adjustAmount)
|
||
if (Number.isNaN(amt) || amt === 0) { toast.error('请输入有效金额(正数增加、负数扣减)'); return }
|
||
setAdjustLoading(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>(`/api/admin/users/${user.id}/balance/adjust`, {
|
||
amount: amt,
|
||
remark: adjustRemark || undefined,
|
||
})
|
||
if (res?.success) {
|
||
toast.success('余额已调整')
|
||
setAdjustBalanceOpen(false)
|
||
setAdjustAmount('')
|
||
setAdjustRemark('')
|
||
loadUserDetail()
|
||
onUserUpdated?.()
|
||
} else {
|
||
toast.error('调整失败: ' + (res?.error || ''))
|
||
}
|
||
} catch { toast.error('调整失败') } finally { setAdjustLoading(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 {
|
||
// 购买意向:看浏览/购买轨迹,把章节名备注给神射手(需求:便于 wepop/外部侧识别“想买哪章”)
|
||
const purchaseIntentChapterTitles = Array.from(
|
||
new Set(
|
||
tracks
|
||
.filter((t) => t.action === 'view_chapter' || t.action === 'purchase' || t.action === 'first_pay')
|
||
.map((t) => (t.chapterTitle || t.target || '').trim())
|
||
.filter(Boolean),
|
||
),
|
||
).slice(0, 12)
|
||
const purchaseIntentActions = {
|
||
viewChapter: trackStats.view_chapter || 0,
|
||
purchase: trackStats.purchase || 0,
|
||
firstPay: trackStats.first_pay || 0,
|
||
}
|
||
const purchaseIntentRemark = purchaseIntentChapterTitles.length > 0
|
||
? `意向章节:${purchaseIntentChapterTitles.join('、')}`
|
||
: ''
|
||
const payload = {
|
||
users: [{
|
||
phone: user.phone || '',
|
||
name: user.nickname || '',
|
||
openId: user.openId || '',
|
||
tags: editTags,
|
||
purchaseIntent: purchaseIntentActions,
|
||
purchaseIntentChapters: purchaseIntentChapterTitles,
|
||
remark: purchaseIntentRemark,
|
||
}]
|
||
}
|
||
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,
|
||
fill_avatar: User,
|
||
visit_page: Navigation,
|
||
first_pay: ShoppingBag,
|
||
vip_activate: Crown,
|
||
click_super: Users,
|
||
lead_submit: Phone,
|
||
withdraw: Key,
|
||
referral_bind: Link2,
|
||
card_click: User,
|
||
btn_click: Zap,
|
||
tab_click: Navigation,
|
||
nav_click: Navigation,
|
||
page_view: Navigation,
|
||
search: Navigation,
|
||
}
|
||
const Icon = icons[action] || Clock
|
||
return <Icon className="w-4 h-4" />
|
||
}
|
||
|
||
function trackTargetIsOpaqueId(s: string) {
|
||
const t = String(s || '').trim()
|
||
return t.length > 22 && /^[a-zA-Z0-9_-]+$/.test(t)
|
||
}
|
||
|
||
const journeyInferredTags = useMemo(
|
||
() => inferTagsFromJourney(trackStats, user),
|
||
[trackStats, user],
|
||
)
|
||
|
||
function mergeJourneyTagsIntoSelected() {
|
||
const next = [...editTags]
|
||
for (const t of journeyInferredTags) {
|
||
if (!next.includes(t)) next.push(t)
|
||
}
|
||
setEditTags(next)
|
||
toast.success('已将旅程推断标签合并到已选')
|
||
}
|
||
|
||
if (!open) return null
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={open} onOpenChange={() => onClose()}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[92vh] overflow-hidden flex flex-col p-4 sm:p-5">
|
||
<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 min-h-0 flex-1 overflow-hidden">
|
||
{/* 头部:身份 + 收益(紧凑) */}
|
||
<div className="flex flex-col sm:flex-row gap-2.5 p-2.5 bg-[#0a1628] rounded-lg mb-2 shrink-0">
|
||
<div className="flex gap-2.5 min-w-0 flex-1">
|
||
<div className="w-11 h-11 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-lg text-[#38bdac] shrink-0">
|
||
{resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? (
|
||
<img
|
||
src={resolveAvatarByMbti(user.avatar, user.mbti)}
|
||
className="w-full h-full rounded-full object-cover"
|
||
alt=""
|
||
onError={() => setAvatarBroken(true)}
|
||
/>
|
||
) : (
|
||
user.nickname?.charAt(0) || '?'
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<h3 className="text-base font-bold text-white leading-tight">{user.nickname}</h3>
|
||
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[10px] py-0">管理员</Badge>}
|
||
{user.hasFullBook && <Badge className="bg-green-500/20 text-green-400 border-0 text-[10px] py-0">全书已购</Badge>}
|
||
{user.vipRole && <Badge className="bg-amber-500/20 text-amber-400 border-0 text-[10px] py-0">{user.vipRole}</Badge>}
|
||
</div>
|
||
{user.referralCode && (
|
||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||
推荐码 <code className="text-[#38bdac]">{user.referralCode}</code>
|
||
</p>
|
||
)}
|
||
<div className="mt-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-1.5 text-[11px]">
|
||
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
|
||
<span className="text-gray-500">昵称</span>
|
||
<p className="text-white truncate">{editNickname || user.nickname || '—'}</p>
|
||
</div>
|
||
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
|
||
<span className="text-gray-500">手机号</span>
|
||
<p className="text-white truncate">{editPhone || '—'}</p>
|
||
</div>
|
||
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
|
||
<span className="text-gray-500">微信标识</span>
|
||
<p className="text-white truncate">{editWechatId || '—'}</p>
|
||
</div>
|
||
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
|
||
<span className="text-gray-500">画像</span>
|
||
<p className="text-[#38bdac] truncate">
|
||
{[user.region, user.industry, user.position, user.mbti ? `MBTI ${user.mbti}` : ''].filter(Boolean).join(' · ') || '未完善'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 sm:grid-cols-2 gap-1.5 shrink-0 sm:w-[220px]">
|
||
<div className="rounded-md bg-[#162840] px-2 py-1.5 border border-gray-700/40">
|
||
<p className="text-[9px] text-gray-500 uppercase tracking-wide">累计佣金</p>
|
||
<p className="text-sm font-bold text-[#38bdac] leading-tight">¥{(user.earnings ?? 0).toFixed(2)}</p>
|
||
<p className="text-[9px] text-gray-600">推广/分佣入账</p>
|
||
</div>
|
||
<div className="rounded-md bg-[#162840] px-2 py-1.5 border border-gray-700/40">
|
||
<p className="text-[9px] text-gray-500">待提现</p>
|
||
<p className="text-sm font-bold text-yellow-400 leading-tight">¥{(user.pendingEarnings ?? 0).toFixed(2)}</p>
|
||
<p className="text-[9px] text-gray-600">未打款部分</p>
|
||
</div>
|
||
<div className="rounded-md bg-[#162840] px-2 py-1.5 border border-gray-700/40">
|
||
<div className="flex items-center justify-between gap-1">
|
||
<p className="text-[9px] text-gray-500">账户余额</p>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-5 px-1 text-[9px] text-[#38bdac] hover:bg-[#38bdac]/10"
|
||
onClick={() => {
|
||
setAdjustAmount('')
|
||
setAdjustRemark('')
|
||
setAdjustBalanceOpen(true)
|
||
}}
|
||
>
|
||
调整
|
||
</Button>
|
||
</div>
|
||
<p className="text-sm font-bold text-white leading-tight">¥{(balanceData?.balance ?? 0).toFixed(2)}</p>
|
||
<p className="text-[9px] text-gray-600">可消费/抵扣</p>
|
||
</div>
|
||
<div className="rounded-md bg-[#162840] px-2 py-1.5 border border-gray-700/40">
|
||
<p className="text-[9px] text-gray-500">推荐人数</p>
|
||
<p className="text-sm font-bold text-white leading-tight">{user.referralCount ?? 0}</p>
|
||
<p className="text-[9px] text-gray-600">{user.createdAt ? `注册 ${new Date(user.createdAt).toLocaleDateString()}` : '—'}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-0.5 mb-2 flex-wrap h-auto gap-0.5 shrink-0">
|
||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-[11px] px-2 py-1 h-7">
|
||
用户信息
|
||
</TabsTrigger>
|
||
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-[11px] px-2 py-1 h-7">
|
||
<Navigation className="w-3 h-3 mr-0.5" />
|
||
旅程与轨迹
|
||
</TabsTrigger>
|
||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-[11px] px-2 py-1 h-7">
|
||
关系链路
|
||
</TabsTrigger>
|
||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-[11px] px-2 py-1 h-7">
|
||
标签体系
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* ===== 用户信息(紧凑单屏):基础字段 + 超级个体 + 外部同步合并为一屏滚动 ===== */}
|
||
<TabsContent value="info" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
|
||
<details className="rounded-lg bg-[#0a1628] border border-gray-700/40 p-2 text-[11px] group">
|
||
<summary className="cursor-pointer text-gray-400 select-none list-none flex items-center gap-1">
|
||
<span className="group-open:text-[#38bdac]">技术标识</span>
|
||
<span className="text-gray-600">(用户ID / OpenID,默认折叠)</span>
|
||
</summary>
|
||
<div className="mt-2 space-y-1.5 text-gray-300 font-mono text-[10px] break-all border-t border-gray-700/30 pt-2">
|
||
<p>
|
||
<span className="text-gray-500 not-italic font-sans">用户ID</span> {user.id}
|
||
</p>
|
||
<p>
|
||
<span className="text-gray-500 not-italic font-sans">OpenID</span> {user.openId || '—'}
|
||
</p>
|
||
<p className="text-gray-500 not-italic font-sans leading-snug">
|
||
OpenID 为微信用户标识;下方「微信标识」为微信号/wxid,供存客宝归属,与 OpenID 不同。
|
||
</p>
|
||
</div>
|
||
</details>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-[11px]">昵称</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-8 text-xs" placeholder="昵称" value={editNickname} onChange={(e) => setEditNickname(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-[11px]">手机号(可改,点底部保存生效)</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-8 text-xs" placeholder="11 位手机号" value={editPhone} onChange={(e) => setEditPhone(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-[11px]">微信标识(微信号/wxid,非 OpenID)</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-8 text-xs" placeholder="如 wxid_xxx 或自定义微信号" value={editWechatId} onChange={(e) => setEditWechatId(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{(user.region || user.industry || user.position || user.mbti) && (
|
||
<div className="flex flex-wrap gap-1.5 text-[11px]">
|
||
{user.region && (
|
||
<span className="px-2 py-0.5 rounded bg-[#162840] text-gray-300">
|
||
<MapPin className="w-3 h-3 inline mr-0.5" />
|
||
{user.region}
|
||
</span>
|
||
)}
|
||
{user.industry && <span className="px-2 py-0.5 rounded bg-[#162840] text-gray-300">行业 {user.industry}</span>}
|
||
{user.position && <span className="px-2 py-0.5 rounded bg-[#162840] text-gray-300">职位 {user.position}</span>}
|
||
{user.mbti && <span className="px-2 py-0.5 rounded bg-[#38bdac]/15 text-[#38bdac]">MBTI {user.mbti}</span>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="p-2 rounded-lg bg-[#0a1628] border border-amber-500/25">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<Crown className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||
<span className="text-white text-xs font-medium">超级个体</span>
|
||
{user.isVip && <Badge className="bg-amber-500/20 text-amber-400 border-0 text-[10px] py-0 shrink-0">{user.vipRole || 'VIP'}</Badge>}
|
||
</div>
|
||
<Switch className="scale-90" checked={vipForm.isVip} onCheckedChange={(c) => setVipForm((f) => ({ ...f, isVip: c }))} />
|
||
</div>
|
||
{vipForm.isVip && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5 mt-2">
|
||
<div className="space-y-0.5">
|
||
<Label className="text-gray-500 text-[10px]">到期日</Label>
|
||
<Input type="date" className="bg-[#162840] border-gray-700 text-white h-7 text-xs" value={vipForm.vipExpireDate} onChange={(e) => setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-gray-500 text-[10px]">角色</Label>
|
||
<select className="w-full bg-[#162840] border border-gray-700 text-white rounded px-1.5 h-7 text-xs" 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-0.5">
|
||
<Label className="text-gray-500 text-[10px]">展示名</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="展示名" value={vipForm.vipName} onChange={(e) => setVipForm((f) => ({ ...f, vipName: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-gray-500 text-[10px]">项目</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="项目" value={vipForm.vipProject} onChange={(e) => setVipForm((f) => ({ ...f, vipProject: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-gray-500 text-[10px]">联系方式</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="微信/手机" value={vipForm.vipContact} onChange={(e) => setVipForm((f) => ({ ...f, vipContact: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-0.5 sm:col-span-2">
|
||
<Label className="text-gray-500 text-[10px]">简介</Label>
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="简短介绍" value={vipForm.vipBio} onChange={(e) => setVipForm((f) => ({ ...f, vipBio: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-2 rounded-lg bg-[#0a1628] border border-[#38bdac]/20">
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<Zap className="w-3.5 h-3.5 text-[#38bdac]" />
|
||
<span className="text-white text-xs font-medium">外部资料 · 神射手 / 存客宝(与上方基础信息联动)</span>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1.5 mb-1.5">
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="查:手机" value={sssQueryPhone} onChange={(e) => setSssQueryPhone(e.target.value)} />
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="查:微信号" value={sssQueryWechatId} onChange={(e) => setSssQueryWechatId(e.target.value)} />
|
||
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="查:OpenID" value={sssQueryOpenId} onChange={(e) => setSssQueryOpenId(e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
<Button size="sm" className="h-7 text-[11px] px-2 bg-[#38bdac] hover:bg-[#2da396]" onClick={handleSSSQuery} disabled={sssLoading}>
|
||
{sssLoading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3 mr-0.5" />}
|
||
查询回填
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="h-7 text-[11px] px-2 border-purple-500/40 text-purple-300" onClick={handleSSSIngest} disabled={batchIngestLoading || !user.phone}>
|
||
{batchIngestLoading ? '推送…' : '推神射手'}
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="h-7 text-[11px] px-2" onClick={handleSyncCKB} disabled={syncing || !user.phone}>
|
||
{syncing ? '同步…' : '存客宝同步'}
|
||
</Button>
|
||
</div>
|
||
{user.ckbSyncedAt && <p className="text-[10px] text-gray-500 mt-1">最近存客宝同步:{new Date(user.ckbSyncedAt).toLocaleString()}</p>}
|
||
{sssError && <p className="mt-1 text-red-400 text-[11px]">{sssError}</p>}
|
||
{sssData && (
|
||
<div className="mt-1.5 grid grid-cols-2 gap-1.5">
|
||
<div className="p-1.5 bg-[#162840] rounded text-[11px]">
|
||
<span className="text-gray-500">RFM</span> <span className="text-[#38bdac] font-semibold">{sssData.rfm_score ?? '—'}</span>
|
||
</div>
|
||
<div className="p-1.5 bg-[#162840] rounded text-[11px]">
|
||
<span className="text-gray-500">等级</span> <span className="text-white font-semibold">{sssData.user_level ?? '—'}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{batchIngestResult && (
|
||
<p className="mt-1 text-[11px]">{batchIngestResult.error ? <span className="text-red-400">{String(batchIngestResult.error)}</span> : <span className="text-green-400">推送成功</span>}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-2 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<Key className="w-3.5 h-3.5 text-yellow-400" />
|
||
<span className="text-white text-xs font-medium">修改密码</span>
|
||
</div>
|
||
<div className="flex flex-col sm:flex-row gap-1.5 sm:items-center">
|
||
<Input type="password" className="bg-[#162840] border-gray-700 text-white h-7 text-xs flex-1" placeholder="新密码 ≥6 位" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||
<Input type="password" className="bg-[#162840] border-gray-700 text-white h-7 text-xs flex-1" placeholder="确认密码" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||
<Button size="sm" className="h-7 text-[11px] shrink-0 bg-yellow-500/20 text-yellow-300 border border-yellow-500/35 hover:bg-yellow-500/30" onClick={handleSavePassword} disabled={passwordSaving || !newPassword || !confirmPassword}>
|
||
{passwordSaving ? '保存中' : '确认修改'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* ===== 用户旅程 + 行为轨迹 ===== */}
|
||
<TabsContent value="journey" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
|
||
{purchaseList.length > 0 && (
|
||
<div className="p-2 bg-[#0a1628] rounded-lg border border-amber-500/20">
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<ShoppingBag className="w-3.5 h-3.5 text-amber-400" />
|
||
<span className="text-white text-xs font-medium">购买清单({purchaseList.length} 笔)</span>
|
||
</div>
|
||
<div className="space-y-1 max-h-[120px] overflow-y-auto">
|
||
{purchaseList.map((o, i) => (
|
||
<div key={o.orderSn || i} className="flex items-center justify-between p-1.5 bg-[#162840] rounded text-[11px]">
|
||
<div className="min-w-0">
|
||
<span className="text-amber-300">
|
||
{o.productType === 'fullbook' || o.productType === 'vip' ? '全书/VIP' : `章节 ${o.productId || ''}`}
|
||
</span>
|
||
<span className="text-gray-500 ml-2">¥{Number(o.amount || 0).toFixed(2)}</span>
|
||
</div>
|
||
<span className="text-gray-500 text-[10px] shrink-0">{o.createdAt ? new Date(o.createdAt).toLocaleString('zh-CN') : ''}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="p-2 bg-[#0a1628] rounded-lg flex flex-col gap-1.5 text-[11px]">
|
||
<div className="flex items-center gap-1.5 text-gray-400">
|
||
<Navigation className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||
<span>全站埋点共 {tracks.length} 条;用于 RFM 与「标签体系」旅程推断</span>
|
||
</div>
|
||
{Object.keys(trackStats).length > 0 && (
|
||
<div className="flex flex-wrap gap-1 pt-1 border-t border-gray-700/40">
|
||
{Object.entries(trackStats)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([act, n]) => (
|
||
<Badge key={act} variant="outline" className="text-[10px] border-gray-600 text-gray-300 bg-[#162840] py-0 h-5">
|
||
{labelTrackAction(act)} ×{n}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{tracks.length > 0 ? (
|
||
tracks.map((track, idx) => (
|
||
<div key={track.id} className="flex items-start gap-2 p-2 bg-[#0a1628] rounded-lg">
|
||
<div className="flex flex-col items-center shrink-0">
|
||
<div className="w-7 h-7 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-3 bg-gray-700/50 mt-0.5" />}
|
||
</div>
|
||
<div className="flex-1 pb-0.5 min-w-0 text-xs">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<span className="text-white font-medium">{track.actionLabel || track.action}</span>
|
||
{track.moduleLabel && <span className="text-[#38bdac]/90">· {track.moduleLabel}</span>}
|
||
{track.chapterTitle && <span className="text-gray-500">· {track.chapterTitle}</span>}
|
||
</div>
|
||
{track.target &&
|
||
track.target !== track.chapterTitle &&
|
||
!trackTargetIsOpaqueId(track.target) && (
|
||
<p className="text-gray-600 text-[10px] mt-0.5 break-all">详情: {track.target}</p>
|
||
)}
|
||
<p className="text-gray-500 text-[10px] mt-0.5">
|
||
<Clock className="w-3 h-3 inline mr-0.5" />
|
||
{track.timeAgo ? `${track.timeAgo} · ` : ''}
|
||
{track.createdAt ? new Date(track.createdAt).toLocaleString() : ''}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-center py-8 text-gray-500 text-sm">
|
||
<Navigation className="w-8 h-8 text-[#38bdac]/40 mx-auto mb-2" />
|
||
暂无旅程记录
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* ===== 关系链路 ===== */}
|
||
<TabsContent value="relations" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
|
||
<div className="p-2 bg-[#0a1628] rounded-lg border border-[#38bdac]/25">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<Link2 className="w-3.5 h-3.5 text-[#38bdac]" />
|
||
<span className="text-white text-sm font-medium">入站关系链路</span>
|
||
</div>
|
||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 text-[10px]">
|
||
点击 {inboundSource?.totalVisits || 0} 次
|
||
</Badge>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-2">
|
||
<div className="p-1.5 bg-[#162840] rounded text-xs">
|
||
<p className="text-gray-500">首次来自</p>
|
||
<p className="text-white mt-0.5">
|
||
{inboundSource?.firstVisit?.referrerNickname || '—'}
|
||
{inboundSource?.firstVisit?.referrerId ? `(${inboundSource.firstVisit.referrerId})` : ''}
|
||
</p>
|
||
</div>
|
||
<div className="p-1.5 bg-[#162840] rounded text-xs">
|
||
<p className="text-gray-500">最近来自</p>
|
||
<p className="text-white mt-0.5">
|
||
{inboundSource?.latestVisit?.referrerNickname || '—'}
|
||
{inboundSource?.latestVisit?.referrerId ? `(${inboundSource.latestVisit.referrerId})` : ''}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{inboundSource?.activeBinding?.referrerId ? (
|
||
<div className="p-1.5 bg-amber-500/10 border border-amber-500/30 rounded text-xs mb-2">
|
||
<p className="text-amber-300">
|
||
当前绑定:
|
||
{inboundSource.activeBinding.referrerNickname || '微信用户'}
|
||
{`(${inboundSource.activeBinding.referrerId})`}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div className="space-y-1 max-h-[160px] overflow-y-auto">
|
||
{(inboundSource?.visits || []).length > 0 ? (
|
||
(inboundSource?.visits || []).map((v, i) => (
|
||
<div key={`${v.referrerId || 'unknown'}_${i}`} className="flex items-center justify-between p-1.5 bg-[#162840] rounded text-xs">
|
||
<div className="min-w-0">
|
||
<p className="text-white truncate">
|
||
第 {v.seq || i + 1} 次 · {v.referrerNickname || '微信用户'}
|
||
{v.referrerId ? `(${v.referrerId})` : ''}
|
||
</p>
|
||
{v.page ? <p className="text-gray-500 text-[10px] truncate">{v.page}</p> : null}
|
||
</div>
|
||
<span className="text-gray-500 text-[10px] shrink-0">
|
||
{v.visitedAt ? new Date(v.visitedAt).toLocaleString() : ''}
|
||
</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-gray-500 text-sm text-center py-2">暂无来源点击记录</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="p-2 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<Link2 className="w-3.5 h-3.5 text-[#38bdac]" />
|
||
<span className="text-white text-sm font-medium">推荐的用户</span>
|
||
</div>
|
||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 text-[10px]">共 {referrals.length} 人</Badge>
|
||
</div>
|
||
<div className="space-y-1 max-h-[min(280px,40vh)] 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-1.5 bg-[#162840] rounded text-xs">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<div className="w-6 h-6 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[10px] text-[#38bdac] shrink-0">{r.nickname?.charAt(0) || '?'}</div>
|
||
<span className="text-white truncate">{r.nickname}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{r.status === 'vip' && <Badge className="bg-green-500/20 text-green-400 border-0 text-[10px] py-0">已购</Badge>}
|
||
<span className="text-gray-500 text-[10px]">{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
) : (
|
||
<p className="text-gray-500 text-sm text-center py-3">暂无推荐用户</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* ===== 标签体系 ===== */}
|
||
<TabsContent value="tags" className="flex-1 min-h-0 overflow-y-auto space-y-3 pr-0.5">
|
||
<div className="p-2.5 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||
<span className="text-white text-sm font-medium">用户标签</span>
|
||
<span className="text-gray-500 text-[11px]">《一场 Soul 的创业实验》维度</span>
|
||
</div>
|
||
<div className="mb-2 p-2 bg-[#38bdac]/5 border border-[#38bdac]/20 rounded-lg flex items-start gap-2 text-[11px] text-gray-400">
|
||
<CheckCircle2 className="w-3.5 h-3.5 text-[#38bdac] shrink-0 mt-0.5" />
|
||
预设可点选;下方「旅程推断」由轨迹+资料自动算出,可一键并入已选后点弹窗底部保存。
|
||
</div>
|
||
<div className="mb-3 p-2 rounded-lg bg-[#162840]/80 border border-cyan-500/20">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 mb-1.5">
|
||
<span className="text-cyan-300/90 text-xs font-medium">旅程推断标签</span>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 text-[11px] border-cyan-500/40 text-cyan-200 hover:bg-cyan-500/10"
|
||
disabled={journeyInferredTags.length === 0}
|
||
onClick={mergeJourneyTagsIntoSelected}
|
||
>
|
||
合并到已选
|
||
</Button>
|
||
</div>
|
||
{journeyInferredTags.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{journeyInferredTags.map((t) => (
|
||
<Badge
|
||
key={t}
|
||
variant="outline"
|
||
className={`text-[10px] py-0 h-5 border-cyan-500/30 ${editTags.includes(t) ? 'bg-cyan-500/15 text-cyan-200' : 'text-gray-300'}`}
|
||
>
|
||
{editTags.includes(t) ? '✓ ' : ''}
|
||
{t}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-gray-500">暂无推断(无轨迹或行为未命中规则)</p>
|
||
)}
|
||
</div>
|
||
<div className="mb-3 space-y-2">
|
||
{[
|
||
{
|
||
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-[11px] mb-1">{group.category}</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{group.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => {
|
||
if (editTags.includes(tag)) removeTag(tag)
|
||
else setEditTags([...editTags, tag])
|
||
}}
|
||
className={`px-1.5 py-0.5 rounded text-[11px] 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-2">
|
||
<p className="text-gray-500 text-[11px] mb-1.5">已选标签(需保存修改写入库)</p>
|
||
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
|
||
{editTags.map((tag, i) => (
|
||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1 text-[11px] py-0">
|
||
{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-xs">暂未选择</span>}
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
className="bg-[#162840] border-gray-700 text-white flex-1 h-8 text-xs"
|
||
placeholder="自定义标签,回车添加"
|
||
value={newTag}
|
||
onChange={(e) => setNewTag(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||
/>
|
||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396] h-8 text-xs px-3">
|
||
添加
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 存客宝标签(与用户标签共用 ckb_tags,兼容 JSON 与逗号分隔) */}
|
||
{(() => {
|
||
const raw = user.tags || user.ckbTags || ''
|
||
let arr: string[] = []
|
||
try {
|
||
const parsed = typeof raw === 'string' ? JSON.parse(raw || '[]') : []
|
||
arr = Array.isArray(parsed) ? parsed : (typeof raw === 'string' ? raw.split(',') : [])
|
||
} catch {
|
||
arr = typeof raw === 'string' ? raw.split(',') : []
|
||
}
|
||
const tags = arr.map((t) => String(t).trim()).filter(Boolean)
|
||
if (tags.length === 0) return null
|
||
return (
|
||
<div className="p-2.5 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<Tag className="w-3.5 h-3.5 text-purple-400" />
|
||
<span className="text-white text-sm font-medium">存客宝标签</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{tags.map((tag, i) => (
|
||
<Badge key={i} className="bg-purple-500/20 text-purple-400 border-0 text-[11px] py-0">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</TabsContent>
|
||
|
||
</Tabs>
|
||
|
||
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3 shrink-0">
|
||
<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>
|
||
|
||
<Dialog open={adjustBalanceOpen} onOpenChange={setAdjustBalanceOpen}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle>调整余额</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div>
|
||
<Label className="text-gray-300 text-sm">调整金额(元)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
className="bg-[#0a1628] border-gray-700 text-white mt-1"
|
||
placeholder="正数增加,负数扣减,如 10 或 -5"
|
||
value={adjustAmount}
|
||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-gray-300 text-sm">备注(可选)</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white mt-1"
|
||
placeholder="如:活动补偿"
|
||
value={adjustRemark}
|
||
onChange={(e) => setAdjustRemark(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" onClick={() => setAdjustBalanceOpen(false)} className="border-gray-600 text-gray-300">
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleAdjustBalance} disabled={adjustLoading} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||
{adjustLoading ? '提交中...' : '确认调整'}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|