更新提现功能,新增私钥文件 URL 配置选项以支持从远程拉取私钥,优化相关错误处理逻辑,确保在转账前正确加载私钥。同时,更新文档以反映新的环境变量配置,提升系统的灵活性和用户体验。
This commit is contained in:
479
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
479
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
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,
|
||||
History,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
BookOpen,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
MessageCircle,
|
||||
Clock,
|
||||
Save,
|
||||
X,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post } from '@/api/client'
|
||||
|
||||
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
|
||||
ckb_synced_at?: string
|
||||
}
|
||||
|
||||
interface UserTrack {
|
||||
id: string
|
||||
action: string
|
||||
actionLabel: string
|
||||
target?: string
|
||||
chapterTitle?: string
|
||||
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<unknown[]>([])
|
||||
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])
|
||||
|
||||
async function loadUserDetail() {
|
||||
if (!userId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const userData = await get<{ success?: boolean; user?: UserDetail }>(
|
||||
`/api/db/users?id=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (userData?.success && userData.user) {
|
||||
const u = userData.user
|
||||
setUser(u)
|
||||
setEditPhone(u.phone || '')
|
||||
setEditNickname(u.nickname || '')
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
}
|
||||
try {
|
||||
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
|
||||
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
|
||||
)
|
||||
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
|
||||
} catch {
|
||||
setTracks([])
|
||||
}
|
||||
try {
|
||||
const refData = await get<{ success?: boolean; referrals?: unknown[] }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
|
||||
} catch {
|
||||
setReferrals([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load user detail error:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) {
|
||||
alert('用户未绑定手机号,无法同步')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
action: 'full_sync',
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('同步成功')
|
||||
loadUserDetail()
|
||||
} else {
|
||||
alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: user.id,
|
||||
phone: editPhone || undefined,
|
||||
nickname: editNickname || undefined,
|
||||
tags: JSON.stringify(editTags),
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('保存成功')
|
||||
loadUserDetail()
|
||||
onUserUpdated?.()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save user error:', e)
|
||||
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, React.ComponentType<{ className?: string }>> = {
|
||||
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 type="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>
|
||||
</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">
|
||||
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无行为轨迹</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 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: unknown, i: number) => {
|
||||
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
|
||||
return (
|
||||
<div key={r.id || i} 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]">
|
||||
{r.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<span className="text-white text-sm">{r.nickname}</span>
|
||||
</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>
|
||||
)}
|
||||
<span className="text-gray-500 text-xs">
|
||||
{r.createdAt ? new Date(r.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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user