Files
Mycontent/soul-admin/src/components/modules/user/UserDetailModal.tsx
2026-03-24 01:22:50 +08:00

1245 lines
59 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 { 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>
</>
)
}