1265 lines
66 KiB
TypeScript
1265 lines
66 KiB
TypeScript
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>
|
||
)
|
||
}
|