589 lines
24 KiB
TypeScript
589 lines
24 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import {
|
||
User, Phone, Calendar, Tag, History, RefreshCw,
|
||
Link2, BookOpen, ShoppingBag, Users, MessageCircle,
|
||
Clock, CheckCircle, XCircle, Save, X
|
||
} from "lucide-react"
|
||
|
||
interface UserDetailModalProps {
|
||
open: boolean
|
||
onClose: () => void
|
||
userId: string | null
|
||
onUserUpdated?: () => void
|
||
}
|
||
|
||
interface UserDetail {
|
||
id: string
|
||
phone?: string
|
||
nickname: string
|
||
avatar?: string
|
||
wechat_id?: string
|
||
open_id?: string
|
||
referral_code: string
|
||
referred_by?: string
|
||
has_full_book: boolean
|
||
is_admin: boolean
|
||
earnings: number
|
||
pending_earnings: number
|
||
referral_count: number
|
||
created_at: string
|
||
updated_at?: string
|
||
// 标签相关
|
||
tags?: string[]
|
||
ckb_tags?: string[]
|
||
source_tags?: string[]
|
||
merged_tags?: string[]
|
||
// 存客宝同步
|
||
ckb_user_id?: string
|
||
ckb_synced_at?: string
|
||
// 来源信息
|
||
source?: string
|
||
created_by?: string
|
||
matched_by?: string
|
||
}
|
||
|
||
interface UserTrack {
|
||
id: string
|
||
action: string
|
||
actionLabel: string
|
||
target?: string
|
||
chapterTitle?: string
|
||
extraData?: any
|
||
createdAt: string
|
||
timeAgo: string
|
||
}
|
||
|
||
export function UserDetailModal({ open, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
||
const [user, setUser] = useState<UserDetail | null>(null)
|
||
const [tracks, setTracks] = useState<UserTrack[]>([])
|
||
const [referrals, setReferrals] = useState<any[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [activeTab, setActiveTab] = useState("info")
|
||
|
||
// 可编辑字段
|
||
const [editPhone, setEditPhone] = useState("")
|
||
const [editNickname, setEditNickname] = useState("")
|
||
const [editTags, setEditTags] = useState<string[]>([])
|
||
const [newTag, setNewTag] = useState("")
|
||
|
||
// 加载用户详情
|
||
useEffect(() => {
|
||
if (open && userId) {
|
||
loadUserDetail()
|
||
}
|
||
}, [open, userId])
|
||
|
||
const loadUserDetail = async () => {
|
||
if (!userId) return
|
||
|
||
setLoading(true)
|
||
try {
|
||
// 加载用户基础信息
|
||
const userRes = await fetch(`/api/db/users?id=${userId}`)
|
||
const userData = await userRes.json()
|
||
|
||
if (userData.success && userData.user) {
|
||
const u = userData.user
|
||
setUser(u)
|
||
setEditPhone(u.phone || "")
|
||
setEditNickname(u.nickname || "")
|
||
setEditTags(u.tags ? JSON.parse(u.tags) : [])
|
||
}
|
||
|
||
// 🔥 加载行为轨迹(可能接口未实现,静默失败)
|
||
try {
|
||
const trackRes = await fetch(`/api/user/track?userId=${userId}&limit=50`)
|
||
if (trackRes.ok) {
|
||
const trackData = await trackRes.json()
|
||
if (trackData.success) {
|
||
setTracks(trackData.tracks || [])
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.log("行为轨迹接口暂未实现,显示占位内容")
|
||
setTracks([])
|
||
}
|
||
|
||
// 🔥 加载绑定关系(静默失败)
|
||
try {
|
||
const refRes = await fetch(`/api/db/users/referrals?userId=${userId}`)
|
||
if (refRes.ok) {
|
||
const refData = await refRes.json()
|
||
if (refData.success) {
|
||
setReferrals(refData.referrals || [])
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.log("绑定关系加载失败,使用默认数据")
|
||
setReferrals([])
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error("Load user detail error:", error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// 同步存客宝数据
|
||
const handleSyncCKB = async () => {
|
||
if (!user?.phone) {
|
||
alert("用户未绑定手机号,无法同步")
|
||
return
|
||
}
|
||
|
||
setSyncing(true)
|
||
try {
|
||
const res = await fetch("/api/ckb/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
action: "full_sync",
|
||
phone: user.phone,
|
||
userId: user.id
|
||
})
|
||
})
|
||
|
||
const data = await res.json()
|
||
if (data.success) {
|
||
alert("同步成功")
|
||
loadUserDetail()
|
||
} else {
|
||
alert("同步失败: " + (data.error || "未知错误"))
|
||
}
|
||
} catch (error) {
|
||
console.error("Sync CKB error:", error)
|
||
alert("同步失败")
|
||
} finally {
|
||
setSyncing(false)
|
||
}
|
||
}
|
||
|
||
// 保存用户信息
|
||
const handleSave = async () => {
|
||
if (!user) return
|
||
|
||
setSaving(true)
|
||
try {
|
||
const res = await fetch("/api/db/users", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: user.id,
|
||
phone: editPhone || undefined,
|
||
nickname: editNickname || undefined,
|
||
tags: JSON.stringify(editTags)
|
||
})
|
||
})
|
||
|
||
const data = await res.json()
|
||
if (data.success) {
|
||
alert("保存成功")
|
||
loadUserDetail()
|
||
onUserUpdated?.()
|
||
} else {
|
||
alert("保存失败: " + (data.error || "未知错误"))
|
||
}
|
||
} catch (error) {
|
||
console.error("Save user error:", error)
|
||
alert("保存失败")
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
// 添加标签
|
||
const addTag = () => {
|
||
if (newTag && !editTags.includes(newTag)) {
|
||
setEditTags([...editTags, newTag])
|
||
setNewTag("")
|
||
}
|
||
}
|
||
|
||
// 移除标签
|
||
const removeTag = (tag: string) => {
|
||
setEditTags(editTags.filter(t => t !== tag))
|
||
}
|
||
|
||
// 获取行为图标
|
||
const getActionIcon = (action: string) => {
|
||
const icons: Record<string, any> = {
|
||
'view_chapter': BookOpen,
|
||
'purchase': ShoppingBag,
|
||
'match': Users,
|
||
'login': User,
|
||
'register': User,
|
||
'share': Link2,
|
||
'bind_phone': Phone,
|
||
'bind_wechat': MessageCircle,
|
||
}
|
||
const Icon = icons[action] || History
|
||
return <Icon className="w-4 h-4" />
|
||
}
|
||
|
||
if (!open) return null
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={() => onClose()}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<User className="w-5 h-5 text-[#38bdac]" />
|
||
用户详情
|
||
{user?.phone && (
|
||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-2">
|
||
已绑定手机
|
||
</Badge>
|
||
)}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
) : user ? (
|
||
<div className="flex flex-col h-[70vh]">
|
||
{/* 用户头部信息 */}
|
||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
|
||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
|
||
{user.avatar ? (
|
||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||
) : (
|
||
user.nickname?.charAt(0) || "?"
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
|
||
{user.is_admin && (
|
||
<Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>
|
||
)}
|
||
{user.has_full_book && (
|
||
<Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-gray-400 text-sm mt-1">
|
||
{user.phone ? `📱 ${user.phone}` : "未绑定手机"}
|
||
{user.wechat_id && ` · 💬 ${user.wechat_id}`}
|
||
</p>
|
||
<p className="text-gray-500 text-xs mt-1">
|
||
ID: {user.id} · 推广码: {user.referral_code}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
|
||
<p className="text-gray-500 text-xs">累计收益</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标签页 */}
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-4">
|
||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||
基础信息
|
||
</TabsTrigger>
|
||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||
标签体系
|
||
</TabsTrigger>
|
||
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||
行为轨迹
|
||
</TabsTrigger>
|
||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||
关系链路
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* 基础信息 */}
|
||
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">手机号(唯一标识)</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="输入手机号"
|
||
value={editPhone}
|
||
onChange={(e) => setEditPhone(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={editNickname}
|
||
onChange={(e) => setEditNickname(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<p className="text-gray-400 text-sm">推荐人数</p>
|
||
<p className="text-2xl font-bold text-white">{user.referral_count || 0}</p>
|
||
</div>
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<p className="text-gray-400 text-sm">待提现</p>
|
||
<p className="text-2xl font-bold text-yellow-400">¥{(user.pending_earnings || 0).toFixed(2)}</p>
|
||
</div>
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<p className="text-gray-400 text-sm">创建时间</p>
|
||
<p className="text-sm text-white">{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 存客宝同步状态 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||
<span className="text-white font-medium">存客宝同步</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSyncCKB}
|
||
disabled={syncing || !user.phone}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{syncing ? (
|
||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...</>
|
||
) : (
|
||
<><RefreshCw className="w-4 h-4 mr-1" /> 同步数据</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-500">同步状态:</span>
|
||
{user.ckb_synced_at ? (
|
||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-1">已同步</Badge>
|
||
) : (
|
||
<Badge className="bg-gray-500/20 text-gray-400 border-0 ml-1">未同步</Badge>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">最后同步:</span>
|
||
<span className="text-gray-300 ml-1">
|
||
{user.ckb_synced_at ? new Date(user.ckb_synced_at).toLocaleString() : '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 标签体系 */}
|
||
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
|
||
{/* 系统标签 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||
<span className="text-white font-medium">系统标签</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{editTags.map((tag, i) => (
|
||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||
{tag}
|
||
<button onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
{editTags.length === 0 && <span className="text-gray-500 text-sm">暂无标签</span>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||
placeholder="添加新标签"
|
||
value={newTag}
|
||
onChange={(e) => setNewTag(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||
/>
|
||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">添加</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 存客宝标签 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Link2 className="w-4 h-4 text-blue-400" />
|
||
<span className="text-white font-medium">存客宝标签</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(user.ckb_tags ? JSON.parse(user.ckb_tags) : []).map((tag: string, i: number) => (
|
||
<Badge key={i} className="bg-blue-500/20 text-blue-400 border-0">{tag}</Badge>
|
||
))}
|
||
{(!user.ckb_tags || JSON.parse(user.ckb_tags).length === 0) && (
|
||
<span className="text-gray-500 text-sm">同步后显示</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 来源标签 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Users className="w-4 h-4 text-orange-400" />
|
||
<span className="text-white font-medium">来源标签</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge className="bg-orange-500/20 text-orange-400 border-0">
|
||
{user.open_id ? '微信小程序' : '手动创建'}
|
||
</Badge>
|
||
{user.referred_by && (
|
||
<Badge className="bg-purple-500/20 text-purple-400 border-0">
|
||
推荐来源: {user.referred_by.slice(0, 8)}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 行为轨迹 */}
|
||
<TabsContent value="tracks" className="flex-1 overflow-auto">
|
||
<div className="space-y-2">
|
||
{tracks.length > 0 ? (
|
||
tracks.map((track) => (
|
||
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
|
||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||
{getActionIcon(track.action)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-white font-medium">{track.actionLabel}</span>
|
||
{track.chapterTitle && (
|
||
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
|
||
)}
|
||
</div>
|
||
<p className="text-gray-500 text-xs mt-1">
|
||
<Clock className="w-3 h-3 inline mr-1" />
|
||
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-center py-12">
|
||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-[#38bdac]/20 to-[#38bdac]/5 flex items-center justify-center">
|
||
<History className="w-10 h-10 text-[#38bdac]/40" />
|
||
</div>
|
||
<p className="text-gray-400 mb-2">📊 行为轨迹功能开发中</p>
|
||
<p className="text-gray-600 text-sm">将记录用户的阅读、购买、分享等行为</p>
|
||
<div className="mt-6 p-4 bg-[#0a1628] rounded-lg text-left">
|
||
<p className="text-gray-500 text-xs mb-2">即将支持的功能:</p>
|
||
<ul className="space-y-1 text-gray-600 text-xs">
|
||
<li>✓ 章节阅读记录</li>
|
||
<li>✓ 购买行为追踪</li>
|
||
<li>✓ 分享链接点击</li>
|
||
<li>✓ 登录时间记录</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 关系链路 */}
|
||
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
|
||
{/* 来源信息 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Users className="w-4 h-4 text-[#38bdac]" />
|
||
<span className="text-white font-medium">来源追溯</span>
|
||
</div>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">创建方式:</span>
|
||
<span className="text-gray-300">{user.open_id ? '微信授权' : '手动创建'}</span>
|
||
</div>
|
||
{user.referred_by && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">推荐人:</span>
|
||
<span className="text-[#38bdac]">{user.referred_by}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">创建时间:</span>
|
||
<span className="text-gray-300">
|
||
{user.created_at ? new Date(user.created_at).toLocaleString() : '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 推荐的用户 */}
|
||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||
<span className="text-white font-medium">推荐的用户</span>
|
||
</div>
|
||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">
|
||
共 {referrals.length} 人
|
||
</Badge>
|
||
</div>
|
||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||
{referrals.length > 0 ? (
|
||
referrals.map((ref: any) => (
|
||
<div key={ref.id} className="flex items-center justify-between p-2 bg-[#162840] rounded">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-6 h-6 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-xs text-[#38bdac]">
|
||
{ref.nickname?.charAt(0) || "?"}
|
||
</div>
|
||
<span className="text-white text-sm">{ref.nickname}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{ref.status === 'vip' && (
|
||
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs">已购</Badge>
|
||
)}
|
||
<span className="text-gray-500 text-xs">
|
||
{ref.createdAt ? new Date(ref.createdAt).toLocaleDateString() : ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-gray-500 text-sm text-center py-4">暂无推荐用户</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* 底部操作栏 */}
|
||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-700 mt-4">
|
||
<Button
|
||
variant="outline"
|
||
onClick={onClose}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
<X className="w-4 h-4 mr-2" />
|
||
关闭
|
||
</Button>
|
||
<Button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
<Save className="w-4 h-4 mr-2" />
|
||
{saving ? "保存中..." : "保存修改"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-gray-500">用户不存在</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|