Files
soul-yongping/soul-admin/src/pages/users/UsersPage.tsx

1265 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import toast from '@/utils/toast'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Search,
UserPlus,
Trash2,
Edit3,
Save,
X,
RefreshCw,
Users,
Eye,
Plus,
BookOpen,
Settings,
PenLine,
Navigation,
TrendingUp,
ArrowUpDown,
ChevronDown,
ChevronUp,
Crown,
Tag,
UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
import { get, del, post, put } from '@/api/client'
interface User {
id: string
openId?: string | null
phone?: string | null
nickname: string
wechatId?: string | null
avatar?: string | null
isAdmin?: boolean | number
hasFullBook?: boolean | number
referralCode?: string
earnings: number | string
pendingEarnings?: number | string
withdrawnEarnings?: number | string
referralCount?: number
createdAt: string
updatedAt?: string | null
// RFM排序模式时有值
rfmScore?: number
rfmLevel?: string
}
interface UserRule {
id: number
title: string
description: string
trigger: string
sort: number
enabled: boolean
createdAt?: string
}
interface VipMember {
id: string
name: string
avatar?: string | null
vipRole?: string | null
vipSort?: number | null
}
// 用户旅程阶段定义
const JOURNEY_STAGES = [
{ id: 'register', label: '注册/登录', icon: '👤', color: 'bg-blue-500/20 border-blue-500/40 text-blue-400', desc: '微信授权登录或手机号注册' },
{ id: 'browse', label: '浏览章节', icon: '📖', color: 'bg-purple-500/20 border-purple-500/40 text-purple-400', desc: '点击免费/付费章节预览' },
{ id: 'bind_phone', label: '绑定手机', icon: '📱', color: 'bg-cyan-500/20 border-cyan-500/40 text-cyan-400', desc: '触发付费章节后绑定手机' },
{ id: 'first_pay', label: '首次付款', icon: '💳', color: 'bg-green-500/20 border-green-500/40 text-green-400', desc: '购买单章或全书' },
{ id: 'fill_profile', label: '完善资料', icon: '✍️', color: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400', desc: '填写头像、MBTI、行业等' },
{ id: 'match', label: '派对房匹配', icon: '🤝', color: 'bg-orange-500/20 border-orange-500/40 text-orange-400', desc: '参与 Soul 派对房' },
{ id: 'vip', label: '升级 VIP', icon: '👑', color: 'bg-amber-500/20 border-amber-500/40 text-amber-400', desc: '付款 ¥1980 购买全书' },
{ id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' },
]
export function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams()
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
const tabParam = searchParams.get('tab') || 'users' // users | journey | rules | vip-roles | leads
// ===== 用户列表 state =====
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const initialVipFilter = poolParam === 'vip' ? 'vip' : poolParam === 'complete' ? 'complete' : 'all'
const [vipFilter, setVipFilter] = useState<'all' | 'vip' | 'complete'>(initialVipFilter)
const [isLoading, setIsLoading] = useState(true)
const [isRefreshLoading, setIsRefreshLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rfmSortMode, setRfmSortMode] = useState(false)
const [rfmSortDir, setRfmSortDir] = useState<'desc' | 'asc'>('desc')
useEffect(() => {
if (poolParam === 'vip') setVipFilter('vip')
else if (poolParam === 'complete') setVipFilter('complete')
else if (poolParam === 'all') setVipFilter('all')
}, [poolParam])
// 弹框
const [showUserModal, setShowUserModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [showReferralsModal, setShowReferralsModal] = useState(false)
const [referralsData, setReferralsData] = useState<{ referrals?: unknown[]; stats?: Record<string, unknown> }>({ referrals: [], stats: {} })
const [referralsLoading, setReferralsLoading] = useState(false)
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [formData, setFormData] = useState({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false })
// ===== 规则管理 =====
const [rules, setRules] = useState<UserRule[]>([])
const [rulesLoading, setRulesLoading] = useState(false)
const [showRuleModal, setShowRuleModal] = useState(false)
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
// ===== 超级个体VIP 用户列表) =====
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
const [vipMembersLoading, setVipMembersLoading] = useState(false)
const [draggingVipId, setDraggingVipId] = useState<string | null>(null)
const [dragOverVipId, setDragOverVipId] = useState<string | null>(null)
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
// ===== 获客列表(存客宝) =====
const [leadsRecords, setLeadsRecords] = useState<{
id: number
userId?: string
userNickname?: string
phone?: string
wechatId?: string
name?: string
source?: string
personName?: string
ckbPlanId?: number
createdAt?: string
}[]>([])
const [leadsTotal, setLeadsTotal] = useState(0)
const [leadsPage, setLeadsPage] = useState(1)
const [leadsPageSize] = useState(20)
const [leadsLoading, setLeadsLoading] = useState(false)
const loadLeads = useCallback(async () => {
setLeadsLoading(true)
try {
const data = await get<{ success?: boolean; records?: unknown[]; total?: number }>(
`/api/db/ckb-leads?mode=contact&page=${leadsPage}&pageSize=${leadsPageSize}`
)
if (data?.success) {
setLeadsRecords((data.records || []) as typeof leadsRecords)
setLeadsTotal(data.total ?? 0)
}
} catch {
setLeadsRecords([])
setLeadsTotal(0)
} finally {
setLeadsLoading(false)
}
}, [leadsPage, leadsPageSize])
useEffect(() => {
if (searchParams.get('tab') === 'leads') loadLeads()
}, [searchParams.get('tab'), leadsPage, loadLeads])
// ===== 在线人数WSS 占位) =====
const [onlineCount, setOnlineCount] = useState<number | null>(null)
const loadOnlineStats = useCallback(async () => {
try {
const data = await get<{ success?: boolean; onlineCount?: number }>('/api/admin/users/online-stats')
if (data?.success && typeof data.onlineCount === 'number') setOnlineCount(data.onlineCount)
else setOnlineCount(0)
} catch {
setOnlineCount(null)
}
}, [])
useEffect(() => {
loadOnlineStats()
const t = setInterval(loadOnlineStats, 10000)
return () => clearInterval(t)
}, [loadOnlineStats])
// ===== 用户列表 =====
async function loadUsers(fromRefresh = false) {
setIsLoading(true)
if (fromRefresh) setIsRefreshLoading(true)
setError(null)
try {
if (rfmSortMode) {
// RFM 排序模式:从 RFM 接口获取
const params = new URLSearchParams({ search: debouncedSearch, limit: String(pageSize * 5) })
const data = await get<{ success?: boolean; users?: User[]; error?: string }>(`/api/db/users/rfm?${params}`)
if (data?.success) {
let list = data.users || []
if (rfmSortDir === 'asc') list = [...list].reverse()
const start = (page - 1) * pageSize
setUsers(list.slice(start, start + pageSize))
setTotal(data.users?.length ?? 0)
if (list.length === 0) {
// 暂无订单数据,退回普通模式
setRfmSortMode(false)
setError('暂无订单数据RFM 排序需要用户有购买记录后才能生效')
}
} else {
setRfmSortMode(false)
setError(data?.error || 'RFM 加载失败,已切回普通模式')
}
} else {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
search: debouncedSearch,
...(vipFilter === 'vip' && { vip: 'true' }),
...(vipFilter === 'complete' && { pool: 'complete' }),
})
const data = await get<{ success?: boolean; users?: User[]; total?: number; error?: string }>(`/api/db/users?${params}`)
if (data?.success) {
setUsers(data.users || [])
setTotal(data.total ?? 0)
} else {
setError(data?.error || '加载失败')
}
}
} catch (err) {
console.error('Load users error:', err)
setError('网络错误')
} finally {
setIsLoading(false)
if (fromRefresh) setIsRefreshLoading(false)
}
}
useEffect(() => { setPage(1) }, [debouncedSearch, vipFilter, rfmSortMode])
useEffect(() => { loadUsers() }, [page, pageSize, debouncedSearch, vipFilter, rfmSortMode, rfmSortDir])
const totalPages = Math.ceil(total / pageSize) || 1
const toggleRfmSort = () => {
if (rfmSortMode) {
if (rfmSortDir === 'desc') setRfmSortDir('asc')
else { setRfmSortMode(false); setRfmSortDir('desc') }
} else {
setRfmSortMode(true)
setRfmSortDir('desc')
}
}
const getRFMLevelColor = (level?: string) => {
const map: Record<string, string> = {
S: 'bg-amber-500/20 text-amber-400',
A: 'bg-green-500/20 text-green-400',
B: 'bg-blue-500/20 text-blue-400',
C: 'bg-gray-500/20 text-gray-400',
D: 'bg-red-500/20 text-red-400',
}
return map[level || ''] || 'bg-gray-500/20 text-gray-400'
}
async function handleDelete(userId: string) {
if (!confirm('确定要删除这个用户吗?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) {
toast.success('已删除')
loadUsers()
} else {
toast.error('删除失败: ' + (data?.error || '未知错误'))
}
} catch (e) {
const err = e as Error & { data?: { error?: string } }
const msg = err?.data?.error || err?.message || '网络错误'
toast.error('删除失败: ' + msg)
}
}
const handleEditUser = (user: User) => {
setEditingUser(user)
setFormData({ phone: user.phone || '', nickname: user.nickname || '', password: '', isAdmin: !!(user.isAdmin ?? false), hasFullBook: !!(user.hasFullBook ?? false) })
setShowUserModal(true)
}
const handleAddUser = () => {
setEditingUser(null)
setFormData({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false })
setShowUserModal(true)
}
async function handleSaveUser() {
if (!formData.phone || !formData.nickname) { toast.error('请填写手机号和昵称'); return }
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, phone: formData.phone || undefined, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, nickname: formData.nickname, password: formData.password, isAdmin: formData.isAdmin })
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
}
setShowUserModal(false)
loadUsers()
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleViewReferrals(user: User) {
setSelectedUserForReferrals(user); setShowReferralsModal(true); setReferralsLoading(true)
try {
const data = await get<{ success?: boolean; referrals?: unknown[]; stats?: Record<string, unknown> }>(`/api/db/users/referrals?userId=${encodeURIComponent(user.id)}`)
if (data?.success) setReferralsData({ referrals: data.referrals || [], stats: data.stats || {} })
else setReferralsData({ referrals: [], stats: {} })
} catch { setReferralsData({ referrals: [], stats: {} }) } finally { setReferralsLoading(false) }
}
// ===== 规则管理 =====
const loadRules = useCallback(async () => {
setRulesLoading(true)
try {
const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules')
if (data?.success) setRules(data.rules || [])
} catch { } finally { setRulesLoading(false) }
}, [])
async function handleSaveRule() {
if (!ruleForm.title) { toast.error('请填写规则标题'); return }
setIsSaving(true)
try {
if (editingRule) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { id: editingRule.id, ...ruleForm })
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm)
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
}
setShowRuleModal(false); loadRules()
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleDeleteRule(id: number) {
if (!confirm('确定删除?')) return
try {
const data = await del<{ success?: boolean }>(`/api/db/user-rules?id=${id}`)
if (data?.success) loadRules()
} catch { }
}
async function handleToggleRule(rule: UserRule) {
try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { }
}
// ===== 超级个体VIP 用户列表) =====
const loadVipMembers = useCallback(async () => {
setVipMembersLoading(true)
try {
const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>(
'/api/db/vip-members?limit=500',
)
if (data?.success && data.data) {
const list = [...data.data].map((m, idx) => ({
...m,
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
}))
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
setVipMembers(list)
} else if (data && data.error) {
toast.error(data.error)
}
} catch {
toast.error('加载超级个体列表失败')
} finally {
setVipMembersLoading(false)
}
}, [])
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
const [vipRoleInput, setVipRoleInput] = useState('')
const [vipRoleSaving, setVipRoleSaving] = useState(false)
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
const openVipRoleModal = (member: VipMember) => {
setVipRoleModalMember(member)
setVipRoleInput(member.vipRole || '')
setShowVipRoleModal(true)
}
const handleSetVipRole = async (value: string) => {
const trimmed = value.trim()
if (!vipRoleModalMember) return
if (!trimmed) {
toast.error('请选择或输入标签')
return
}
setVipRoleSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: vipRoleModalMember.id,
vipRole: trimmed,
})
if (!res?.success) {
toast.error(res?.error || '更新超级个体标签失败')
return
}
toast.success('已更新超级个体标签')
setShowVipRoleModal(false)
setVipRoleModalMember(null)
await loadVipMembers()
} catch {
toast.error('更新超级个体标签失败')
} finally {
setVipRoleSaving(false)
}
}
const [showVipSortModal, setShowVipSortModal] = useState(false)
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
const [vipSortInput, setVipSortInput] = useState('')
const [vipSortSaving, setVipSortSaving] = useState(false)
const openVipSortModal = (member: VipMember) => {
setVipSortModalMember(member)
setVipSortInput(member.vipSort != null ? String(member.vipSort) : '')
setShowVipSortModal(true)
}
const handleSetVipSort = async () => {
if (!vipSortModalMember) return
const num = Number(vipSortInput)
if (!Number.isFinite(num)) {
toast.error('请输入有效的数字序号')
return
}
setVipSortSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: vipSortModalMember.id,
vipSort: num,
})
if (!res?.success) {
toast.error(res?.error || '更新排序序号失败')
return
}
toast.success('已更新排序序号')
setShowVipSortModal(false)
setVipSortModalMember(null)
await loadVipMembers()
} catch {
toast.error('更新排序序号失败')
} finally {
setVipSortSaving(false)
}
}
const handleVipRowDragStart = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
setDraggingVipId(id)
}
const handleVipRowDragOver = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
e.preventDefault()
if (dragOverVipId !== id) setDragOverVipId(id)
}
const handleVipRowDragEnd = () => {
setDraggingVipId(null)
setDragOverVipId(null)
}
const handleVipRowDrop = async (e: React.DragEvent<HTMLTableRowElement>, targetId: string) => {
e.preventDefault()
const fromId = e.dataTransfer.getData('text/plain') || draggingVipId
setDraggingVipId(null)
setDragOverVipId(null)
if (!fromId || fromId === targetId) return
const fromMember = vipMembers.find((m) => m.id === fromId)
const targetMember = vipMembers.find((m) => m.id === targetId)
if (!fromMember || !targetMember) return
const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1
const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1
// 本地先交换顺序,提升交互流畅度
setVipMembers((prev) => {
const list = [...prev]
const fromIdx = list.findIndex((m) => m.id === fromId)
const toIdx = list.findIndex((m) => m.id === targetId)
if (fromIdx === -1 || toIdx === -1) return prev
const next = [...list]
const [m1, m2] = [next[fromIdx], next[toIdx]]
next[fromIdx] = { ...m2, vipSort: fromSort }
next[toIdx] = { ...m1, vipSort: targetSort }
return next
})
try {
const [res1, res2] = await Promise.all([
put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }),
put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }),
])
if (!res1?.success || !res2?.success) {
toast.error(res1?.error || res2?.error || '更新排序失败')
await loadVipMembers()
return
}
toast.success('已更新排序')
await loadVipMembers()
} catch {
toast.error('更新排序失败')
await loadVipMembers()
}
}
// ===== 用户旅程总览 =====
const loadJourneyStats = useCallback(async () => {
setJourneyLoading(true)
try {
const data = await get<{ success?: boolean; stats?: Record<string, number> }>('/api/db/users/journey-stats')
if (data?.success && data.stats) setJourneyStats(data.stats)
} catch { } finally { setJourneyLoading(false) }
}, [])
return (
<div className="p-8 w-full">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>×</button>
</div>
)}
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1 text-sm">
{total}
{onlineCount !== null && <span className="text-[#38bdac] ml-1">· 线 {onlineCount} </span>}
{rfmSortMode && ' · RFM 排序中'}
</p>
</div>
</div>
<Tabs value={tabParam} onValueChange={(v) => { const sp = new URLSearchParams(searchParams); if (v === 'users') sp.delete('tab'); else sp.set('tab', v); setSearchParams(sp) }} className="w-full">
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-6 flex-wrap h-auto gap-1">
<TabsTrigger value="users" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5">
<Users className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="leads" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={() => loadLeads()}>
<LeadIcon className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadJourneyStats}>
<Navigation className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadRules}>
<Settings className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipMembers}>
<Crown className="w-4 h-4" />
</TabsTrigger>
</TabsList>
{/* ===== 用户列表 ===== */}
<TabsContent value="users">
<div className="flex items-center gap-3 mb-4 justify-end flex-wrap">
<Button
variant="outline"
onClick={() => loadUsers(true)}
disabled={isRefreshLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshLoading ? 'animate-spin' : ''}`} />
</Button>
<select
value={vipFilter}
onChange={(e) => {
const v = e.target.value as 'all' | 'vip' | 'complete'
setVipFilter(v); setPage(1)
if (poolParam) { searchParams.delete('pool'); setSearchParams(searchParams) }
}}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
disabled={rfmSortMode}
>
<option value="all"></option>
<option value="vip">VIP会员</option>
<option value="complete"></option>
</select>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
type="text"
placeholder="搜索用户..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-56"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<div>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 cursor-pointer select-none" onClick={toggleRfmSort}>
<div className="flex items-center gap-1 group">
<TrendingUp className="w-3.5 h-3.5" />
<span>RFM分值</span>
{rfmSortMode ? (
rfmSortDir === 'desc'
? <ChevronDown className="w-3.5 h-3.5 text-[#38bdac]" />
: <ChevronUp className="w-3.5 h-3.5 text-[#38bdac]" />
) : (
<ArrowUpDown className="w-3.5 h-3.5 text-gray-600 group-hover:text-gray-400" />
)}
</div>
{rfmSortMode && (
<div className="text-[10px] text-[#38bdac] font-normal mt-0.5">/</div>
)}
</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : user.nickname?.charAt(0) || '?'}
</div>
<div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }}
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{user.nickname}
</button>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs"></Badge>}
{user.openId && !user.id?.startsWith('user_') && <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs"></Badge>}
</div>
<p className="text-xs text-gray-500 font-mono">
{user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
{user.phone && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">📱</span><span className="text-gray-300">{user.phone}</span></div>}
{user.wechatId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">💬</span><span className="text-gray-300">{user.wechatId}</span></div>}
{user.openId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">🔗</span><span className="text-gray-500 truncate max-w-[100px]" title={user.openId}>{user.openId.slice(0, 12)}...</span></div>}
{!user.phone && !user.wechatId && !user.openId && <span className="text-gray-600 text-xs"></span>}
</div>
</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">VIP</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="text-white font-medium">¥{parseFloat(String(user.earnings || 0)).toFixed(2)}</div>
{parseFloat(String(user.pendingEarnings || 0)) > 0 && (
<div className="text-xs text-yellow-400">: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)}</div>
)}
<div className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1" onClick={() => handleViewReferrals(user)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)}>
<Users className="w-3 h-3" /> {user.referralCount || 0}
</div>
</div>
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
</div>
) : (
<span className="text-gray-600 text-sm"> <span className="text-xs text-gray-700"></span></span>
)}
</TableCell>
<TableCell className="text-gray-400">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="用户详情"><Eye className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑用户"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(user.id)} title="删除"><Trash2 className="w-4 h-4" /></Button>
</div>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500"></TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={(n) => { setPageSize(n); setPage(1) }} />
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ===== 获客列表(存客宝) ===== */}
<TabsContent value="leads">
<div className="flex items-center justify-end mb-4">
<Button variant="outline" onClick={loadLeads} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${leadsLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{leadsLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<div>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"> @人</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leadsRecords.map((r) => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-gray-300">{r.userNickname || r.name || '-'}</TableCell>
<TableCell className="text-gray-300">{r.phone || '-'}</TableCell>
<TableCell className="text-gray-300">{r.wechatId || '-'}</TableCell>
<TableCell className="text-[#38bdac]">{r.personName || '-'}</TableCell>
<TableCell className="text-gray-400">{r.ckbPlanId ? `#${r.ckbPlanId}` : '-'}</TableCell>
<TableCell className="text-gray-400">{r.source || '-'}</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{leadsRecords.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500"></TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination
page={leadsPage}
totalPages={Math.ceil(leadsTotal / leadsPageSize) || 1}
total={leadsTotal}
pageSize={leadsPageSize}
onPageChange={setLeadsPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ===== 用户旅程总览 ===== */}
<TabsContent value="journey">
<div className="flex items-center justify-between mb-5">
<p className="text-gray-400 text-sm"> VIP </p>
<Button variant="outline" onClick={loadJourneyStats} disabled={journeyLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${journeyLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 流程图 */}
<div className="relative mb-8">
{/* 连接线 */}
<div className="absolute top-16 left-0 right-0 h-0.5 bg-gradient-to-r from-blue-500/20 via-[#38bdac]/30 to-amber-500/20 mx-20" />
<div className="grid grid-cols-4 gap-4 lg:grid-cols-8">
{JOURNEY_STAGES.map((stage, idx) => (
<div key={stage.id} className="relative flex flex-col items-center">
{/* 阶段卡片 */}
<div className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-default`}>
<div className="text-2xl mb-1">{stage.icon}</div>
<div className={`text-xs font-medium ${stage.color.split(' ').find(c => c.startsWith('text-'))}`}>{stage.label}</div>
{journeyStats[stage.id] !== undefined && (
<div className="mt-1.5 text-xs text-gray-400">
<span className="font-bold text-white">{journeyStats[stage.id]}</span>
</div>
)}
{/* 序号 */}
<div className="absolute -top-2.5 -left-2.5 w-5 h-5 rounded-full bg-[#0a1628] border border-gray-700 flex items-center justify-center text-[10px] text-gray-500">{idx + 1}</div>
</div>
{/* 箭头(非最后一个)*/}
{idx < JOURNEY_STAGES.length - 1 && (
<div className="hidden lg:block absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10">
<svg width="20" height="12" viewBox="0 0 20 12" fill="none">
<path d="M0 6H16M16 6L11 1M16 6L11 11" stroke="#374151" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
)}
{/* 描述 */}
<p className="text-[10px] text-gray-600 text-center mt-2 leading-tight">{stage.desc}</p>
</div>
))}
</div>
</div>
{/* 旅程说明 */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Navigation className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium"></span>
</div>
<div className="space-y-2 text-sm">
{[
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '引导填写头像' },
{ step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' },
{ step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' },
{ step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' },
{ step: '⑤ 分销', action: '推广好友购买', next: '提现分销收益' },
].map((item) => (
<div key={item.step} className="flex items-start gap-3 p-2 bg-[#0a1628] rounded">
<span className="text-[#38bdac] font-mono text-xs shrink-0 mt-0.5">{item.step}</span>
<div>
<p className="text-gray-300">{item.action}</p>
<p className="text-gray-600 text-xs"> {item.next}</p>
</div>
</div>
))}
</div>
</div>
<div className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium"></span>
<span className="text-gray-500 text-xs ml-auto"></span>
</div>
{journeyLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
</div>
) : Object.keys(journeyStats).length > 0 ? (
<div className="space-y-2">
{JOURNEY_STAGES.map((stage) => {
const count = journeyStats[stage.id] || 0
const maxCount = Math.max(...JOURNEY_STAGES.map(s => journeyStats[s.id] || 0), 1)
const pct = Math.round((count / maxCount) * 100)
return (
<div key={stage.id} className="flex items-center gap-2">
<span className="text-gray-500 text-xs w-20 shrink-0">{stage.icon} {stage.label}</span>
<div className="flex-1 h-2 bg-[#0a1628] rounded-full overflow-hidden">
<div className="h-full bg-[#38bdac]/60 rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-gray-400 text-xs w-10 text-right">{count}</span>
</div>
)
})}
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500 text-sm"></p>
</div>
)}
</div>
</div>
</TabsContent>
{/* ===== 规则配置 ===== */}
<TabsContent value="rules">
<div className="mb-4 flex items-center justify-between">
<p className="text-gray-400 text-sm"></p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{rulesLoading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
) : rules.length === 0 ? (
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50">
<BookOpen className="w-12 h-12 text-[#38bdac]/30 mx-auto mb-4" />
<p className="text-gray-400 mb-4">10</p>
<Button onClick={loadRules} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><RefreshCw className="w-4 h-4 mr-2" /> </Button>
</div>
) : (
<div className="space-y-2">
{rules.map((rule) => (
<div key={rule.id} className={`p-4 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<PenLine className="w-4 h-4 text-[#38bdac] shrink-0" />
<span className="text-white font-medium">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{rule.trigger}</Badge>}
<Badge className={`text-xs border-0 ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>{rule.enabled ? '启用' : '禁用'}</Badge>
</div>
{rule.description && <p className="text-gray-400 text-sm ml-6">{rule.description}</p>}
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
{/* ===== 超级个体列表VIP 用户) ===== */}
<TabsContent value="vip-roles">
<div className="mb-4 flex items-center justify-between">
<div className="space-y-1">
<p className="text-gray-400 text-sm">
VIP
</p>
<p className="text-xs text-[#38bdac]">
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={loadVipMembers}
disabled={vipMembersLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw
className={`w-4 h-4 mr-2 ${vipMembersLoading ? 'animate-spin' : ''}`}
/>{' '}
</Button>
</div>
</div>
{vipMembersLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : vipMembers.length === 0 ? (
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50">
<Crown className="w-12 h-12 text-amber-400/30 mx-auto mb-4" />
<p className="text-gray-400 mb-4"></p>
</div>
) : (
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400 w-16"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 min-w-48"></TableHead>
<TableHead className="text-gray-400 w-24"></TableHead>
<TableHead className="text-gray-400 w-40 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vipMembers.map((m, index) => {
const isDragging = draggingVipId === m.id
const isOver = dragOverVipId === m.id
return (
<TableRow
key={m.id}
draggable
onDragStart={(e) => handleVipRowDragStart(e, m.id)}
onDragOver={(e) => handleVipRowDragOver(e, m.id)}
onDrop={(e) => handleVipRowDrop(e, m.id)}
onDragEnd={handleVipRowDragEnd}
className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${
isDragging ? 'opacity-60' : ''
} ${isOver ? 'bg-[#38bdac]/10' : ''}`}
>
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={m.avatar}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
{m.name?.[0] || '创'}
</div>
)}
<div className="min-w-0">
<div className="text-white text-sm truncate">{m.name}</div>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300 whitespace-nowrap">
{m.vipRole || <span className="text-gray-500"></span>}
</TableCell>
<TableCell className="text-gray-300">
{m.vipSort ?? index + 1}
</TableCell>
<TableCell className="text-right text-xs text-gray-300">
<div className="inline-flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-amber-300 hover:text-amber-200"
onClick={() => openVipRoleModal(m)}
title="设置超级个体标签"
>
<Tag className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
onClick={() => {
setSelectedUserIdForDetail(m.id)
setShowDetailModal(true)
}}
title="编辑资料"
>
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-sky-300 hover:text-sky-200"
onClick={() => openVipSortModal(m)}
title="设置排序序号"
>
<ArrowUpDown className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
{/* 设置排序 */}
<Dialog open={showVipSortModal} onOpenChange={(open) => { setShowVipSortModal(open); if (!open) setVipSortModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<ArrowUpDown className="w-5 h-5 text-[#38bdac]" />
{vipSortModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如1"
value={vipSortInput}
onChange={(e) => setVipSortInput(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipSortModal(false)} 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={handleSetVipSort} disabled={vipSortSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipSortSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 设置超级个体标签 */}
<Dialog open={showVipRoleModal} onOpenChange={(open) => { setShowVipRoleModal(open); if (!open) setVipRoleModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
{vipRoleModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm"></Label>
<div className="flex flex-wrap gap-2">
{VIP_ROLE_PRESETS.map((preset) => (
<Button
key={preset}
variant={vipRoleInput === preset ? 'default' : 'outline'}
size="sm"
className={vipRoleInput === preset ? 'bg-[#38bdac] hover:bg-[#2da396] text-white' : 'border-gray-600 text-gray-300 hover:bg-gray-700/50'}
onClick={() => setVipRoleInput(preset)}
>
{preset}
</Button>
))}
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:创业者、资源整合者等"
value={vipRoleInput}
onChange={(e) => setVipRoleInput(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} 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={() => handleSetVipRole(vipRoleInput)} disabled={vipRoleSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipRoleSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入手机号" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入昵称" value={formData.nickname} onChange={(e) => setFormData({ ...formData, nickname: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300">{editingUser ? '新密码 (留空则不修改)' : '密码'}</Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder={editingUser ? '留空则不修改' : '请输入密码'} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} /></div>
<div className="flex items-center justify-between"><Label className="text-gray-300"></Label><Switch checked={formData.isAdmin} onCheckedChange={(c) => setFormData({ ...formData, isAdmin: c })} /></div>
<div className="flex items-center justify-between"><Label className="text-gray-300"></Label><Switch checked={formData.hasFullBook} onCheckedChange={(c) => setFormData({ ...formData, hasFullBook: c })} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUserModal(false)} 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={handleSaveUser} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" />{isSaving ? '保存中...' : '保存'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加/编辑规则 */}
<Dialog open={showRuleModal} onOpenChange={setShowRuleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><PenLine className="w-5 h-5 text-[#38bdac]" />{editingRule ? '编辑规则' : '添加规则'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"> *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例匹配后填写头像、付款1980需填写信息" value={ruleForm.title} onChange={(e) => setRuleForm({ ...ruleForm, title: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[80px] resize-none" placeholder="详细说明规则内容..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:完成匹配、付款后、注册时" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} /></div>
<div className="flex items-center justify-between"><div><Label className="text-gray-300"></Label></div><Switch checked={ruleForm.enabled} onCheckedChange={(c) => setRuleForm({ ...ruleForm, enabled: c })} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRuleModal(false)} 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={handleSaveRule} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" />{isSaving ? '保存中...' : '保存'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 绑定关系 */}
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Users className="w-5 h-5 text-[#38bdac]" /> - {selectedUserForReferrals?.nickname}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-4 gap-3">
<div className="bg-[#0a1628] rounded-lg p-3 text-center"><div className="text-2xl font-bold text-[#38bdac]">{(referralsData.stats?.total as number) || 0}</div><div className="text-xs text-gray-400"></div></div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center"><div className="text-2xl font-bold text-green-400">{(referralsData.stats?.purchased as number) || 0}</div><div className="text-xs text-gray-400"></div></div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center"><div className="text-2xl font-bold text-yellow-400">¥{((referralsData.stats?.earnings as number) || 0).toFixed(2)}</div><div className="text-xs text-gray-400"></div></div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center"><div className="text-2xl font-bold text-orange-400">¥{((referralsData.stats?.pendingEarnings as number) || 0).toFixed(2)}</div><div className="text-xs text-gray-400"></div></div>
</div>
{referralsLoading ? <div className="flex items-center justify-center py-8"><RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
: (referralsData.referrals?.length ?? 0) > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{(referralsData.referrals ?? []).map((ref: unknown, i: number) => {
const r = ref as { id?: string; nickname?: string; phone?: string; hasOpenId?: boolean; status?: string; purchasedSections?: number; createdAt?: string }
return (
<div key={r.id || i} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">{r.nickname?.charAt(0) || '?'}</div>
<div><div className="text-white text-sm">{r.nickname}</div><div className="text-xs text-gray-500">{r.phone || (r.hasOpenId ? '微信用户' : '未绑定')}</div></div>
</div>
<div className="flex items-center gap-2">
{r.status === 'vip' && <Badge className="bg-green-500/20 text-green-400 border-0 text-xs"></Badge>}
{r.status === 'paid' && <Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">{r.purchasedSections}</Badge>}
{r.status === 'free' && <Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs"></Badge>}
<span className="text-xs text-gray-500">{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}</span>
</div>
</div>
)
})}
</div>
) : <div className="text-center py-8 text-gray-500"></div>}
</div>
<DialogFooter><Button variant="outline" onClick={() => setShowReferralsModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button></DialogFooter>
</DialogContent>
</Dialog>
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
</div>
)
}