Files
soul/components/modules/user/user-detail-modal.tsx

563 lines
22 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.

"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) : [])
}
// 加载行为轨迹
const trackRes = await fetch(`/api/user/track?userId=${userId}&limit=50`)
const trackData = await trackRes.json()
if (trackData.success) {
setTracks(trackData.tracks || [])
}
// 加载绑定关系
const refRes = await fetch(`/api/db/users/referrals?userId=${userId}`)
const refData = await refRes.json()
if (refData.success) {
setReferrals(refData.referrals || [])
}
} 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 text-gray-500">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p></p>
</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>
)
}