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

872 lines
52 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 { 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,
Key,
Save,
X,
RefreshCw,
Users,
Eye,
Crown,
Plus,
BookOpen,
Settings,
PenLine,
Navigation,
TrendingUp,
ArrowUpDown,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { SetVipModal } from '@/components/modules/user/SetVipModal'
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 VipRole {
id: number
name: string
sort: number
createdAt?: string
}
// 用户旅程阶段定义
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
// ===== 用户列表 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' : 'all'
const [vipFilter, setVipFilter] = useState<'all' | 'vip' | 'complete'>(initialVipFilter as 'all' | 'vip' | 'complete')
const [isLoading, setIsLoading] = useState(true)
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 [showPasswordModal, setShowPasswordModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
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 [showSetVipModal, setShowSetVipModal] = useState(false)
const [selectedUserForVip, setSelectedUserForVip] = useState<User | 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 [vipRoles, setVipRoles] = useState<VipRole[]>([])
const [vipRolesLoading, setVipRolesLoading] = useState(false)
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
const [editingVipRole, setEditingVipRole] = useState<VipRole | null>(null)
const [vipRoleForm, setVipRoleForm] = useState({ name: '', sort: 0 })
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
// ===== 用户列表 =====
async function loadUsers() {
setIsLoading(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' }),
})
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)
}
}
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) loadUsers()
else alert('删除失败: ' + (data?.error || ''))
} catch { alert('删除失败') }
}
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) { alert('请填写手机号和昵称'); return }
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
if (!data?.success) { alert('更新失败: ' + (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) { alert('创建失败: ' + (data?.error || '')); return }
}
setShowUserModal(false)
loadUsers()
} catch { alert('保存失败') } finally { setIsSaving(false) }
}
const handleChangePassword = (user: User) => { setEditingUser(user); setNewPassword(''); setConfirmPassword(''); setShowPasswordModal(true) }
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) }
}
async function handleSavePassword() {
if (!newPassword) { alert('请输入新密码'); return }
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
if (newPassword.length < 6) { alert('密码至少6位'); return }
setIsSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser?.id, password: newPassword })
if (data?.success) { alert('修改成功'); setShowPasswordModal(false) }
else alert('修改失败: ' + (data?.error || ''))
} catch { alert('修改失败') } finally { setIsSaving(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) { alert('请填写规则标题'); 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) { alert('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm)
if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return }
}
setShowRuleModal(false); loadRules()
} catch { alert('保存失败') } 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 loadVipRoles = useCallback(async () => {
setVipRolesLoading(true)
try {
const data = await get<{ success?: boolean; roles?: VipRole[] }>('/api/db/vip-roles')
if (data?.success) setVipRoles(data.roles || [])
} catch { } finally { setVipRolesLoading(false) }
}, [])
async function handleSaveVipRole() {
if (!vipRoleForm.name) { alert('请填写角色名称'); return }
setIsSaving(true)
try {
if (editingVipRole) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
if (!data?.success) { alert('更新失败'); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
if (!data?.success) { alert('创建失败'); return }
}
setShowVipRoleModal(false); loadVipRoles()
} catch { alert('保存失败') } finally { setIsSaving(false) }
}
async function handleDeleteVipRole(id: number) {
if (!confirm('确定删除?')) return
try {
const data = await del<{ success?: boolean }>(`/api/db/vip-roles?id=${id}`)
if (data?.success) loadVipRoles()
} catch { }
}
// ===== 用户旅程总览 =====
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} {rfmSortMode && ' · RFM 排序中'}</p>
</div>
</div>
<Tabs defaultValue="users" 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="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={loadVipRoles}>
<Crown className="w-4 h-4" /> VIP
</TabsTrigger>
</TabsList>
{/* ===== 用户列表 ===== */}
<TabsContent value="users">
<div className="flex items-center gap-3 mb-4 justify-end flex-wrap">
<Button
variant="outline"
onClick={loadUsers}
disabled={isLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<select
value={vipFilter}
onChange={(e) => { setVipFilter(e.target.value as 'all' | 'vip'); setPage(1) }}
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>
</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">
<p className="font-medium text-white">{user.nickname}</p>
{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={() => { setSelectedUserForVip(user); setShowSetVipModal(true) }} className="text-gray-400 hover:text-amber-400 hover:bg-amber-400/10" title="设置 VIP"><Crown className="w-4 h-4" /></Button>
<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" onClick={() => handleChangePassword(user)} className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10" title="修改密码"><Key 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="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">
<p className="text-gray-400 text-sm"> VIP 使</p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadVipRoles} disabled={vipRolesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${vipRolesLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{vipRolesLoading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
) : vipRoles.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"> VIP </p>
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Plus className="w-4 h-4 mr-2" /> </Button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{vipRoles.map((role) => (
<div key={role.id} className="p-4 bg-[#0f2137] border border-amber-500/20 rounded-xl hover:border-amber-500/40 transition-all group">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">{role.name}</span>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => { setEditingVipRole(role); setVipRoleForm({ name: role.name, sort: role.sort }); setShowVipRoleModal(true) }} className="text-gray-500 hover:text-[#38bdac]"><Edit3 className="w-3.5 h-3.5" /></button>
<button type="button" onClick={() => handleDeleteVipRole(role.id)} className="text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<p className="text-gray-600 text-xs">: {role.sort}</p>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
<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 })} disabled={!!editingUser} /></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={showPasswordModal} onOpenChange={setShowPasswordModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Key className="w-5 h-5 text-[#38bdac]" /></DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-[#0a1628] rounded-lg p-3"><p className="text-gray-400 text-sm">{editingUser?.nickname}</p><p className="text-gray-400 text-sm">{editingUser?.phone}</p></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入新密码 (至少6位)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder="请再次输入" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
<Button onClick={handleSavePassword} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">{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>
{/* 添加/编辑 VIP 角色 */}
<Dialog open={showVipRoleModal} onOpenChange={setShowVipRoleModal}>
<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" />{editingVipRole ? '编辑 VIP 角色' : '添加 VIP 角色'}</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={vipRoleForm.name} onChange={(e) => setVipRoleForm({ ...vipRoleForm, name: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="number" className="bg-[#0a1628] border-gray-700 text-white" value={vipRoleForm.sort} onChange={(e) => setVipRoleForm({ ...vipRoleForm, sort: parseInt(e.target.value) || 0 })} /></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={handleSaveVipRole} 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} />
<SetVipModal open={showSetVipModal} onClose={() => { setShowSetVipModal(false); setSelectedUserForVip(null) }} userId={selectedUserForVip?.id ?? null} userNickname={selectedUserForVip?.nickname} onSaved={loadUsers} />
</div>
)
}