feat(mining-admin-web): 复用admin-web用户管理功能

- 更新用户列表:添加头像、个人/团队认种、推荐人、状态徽章
- 更新用户详情:添加头像、KYC状态、认种统计卡片
- 新增引荐关系Tab:展示引荐人链和直推下级树
- 新增认种信息Tab:认种汇总和认种分类账明细
- 新增钱包信息Tab:钱包汇总和钱包分类账明细
- 更新类型定义和API hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-11 18:58:48 -08:00
parent 5dab829995
commit 8fc527b918
8 changed files with 1238 additions and 107 deletions

View File

@ -1,6 +1,7 @@
'use client';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useUserDetail } from '@/features/users/hooks/use-users';
import { formatDecimal, formatNumber } from '@/lib/utils/format';
@ -8,9 +9,15 @@ import { formatDateTime } from '@/lib/utils/date';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { ContributionRecordsList } from '@/features/users/components/contribution-records-list';
import { MiningRecordsList } from '@/features/users/components/mining-records-list';
import { TradeOrdersList } from '@/features/users/components/trade-orders-list';
import { ReferralTree } from '@/features/users/components/referral-tree';
import { PlantingLedger } from '@/features/users/components/planting-ledger';
import { WalletLedger } from '@/features/users/components/wallet-ledger';
import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins } from 'lucide-react';
function UserDetailSkeleton() {
return (
@ -45,62 +52,128 @@ export default function UserDetailPage() {
);
}
// 获取状态徽章
const getStatusBadge = () => {
if (user?.status === 'frozen') {
return <Badge variant="destructive"></Badge>;
}
if (user?.status === 'deactivated') {
return <Badge variant="secondary"></Badge>;
}
if (user?.isOnline) {
return <Badge className="bg-green-500">线</Badge>;
}
return <Badge variant="outline"></Badge>;
};
return (
<div className="space-y-6">
<PageHeader title="用户详情" description={`账户序列: ${accountSequence}`} backLink="/users" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 基本信息卡片 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="flex items-start gap-6 mb-6">
<div className="relative">
<Avatar className="h-20 w-20">
<AvatarImage src={user?.avatar || undefined} alt={user?.nickname || ''} />
<AvatarFallback className="text-2xl">{user?.nickname?.charAt(0) || 'U'}</AvatarFallback>
</Avatar>
{user?.isOnline && (
<span className="absolute bottom-1 right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold">{user?.nickname || '未设置昵称'}</h2>
{getStatusBadge()}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-mono font-medium">{user?.accountSequence}</p>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono font-medium">{user?.accountSequence}</span>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{user?.nickname || '-'}</p>
<span className="text-muted-foreground">:</span>
<span className="ml-2">{user?.phoneNumberMasked || user?.phone}</span>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{user?.phone}</p>
<span className="text-muted-foreground">KYC状态:</span>
<span className="ml-2">{user?.kycStatus || '未认证'}</span>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user?.hasAdopted ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" />
</p>
{user?.referrerAccountSequence ? (
<Link
href={`/users/${user.referrerAccountSequence}`}
className="font-mono font-medium text-primary hover:underline"
>
{user?.hasAdopted ? '已认种' : '未认种'}
</span>
{user.referrerAccountSequence}
</Link>
) : (
<p className="font-medium">-</p>
)}
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-mono font-medium">{user?.referrerAccountSequence || '-'}</p>
</div>
<div>
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{formatNumber(user?.directReferralCount)}</p>
</div>
<div>
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{formatNumber(user?.directReferralAdoptedCount)}</p>
</div>
<div>
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm">{formatDateTime(user?.createdAt)}</p>
<p className="text-sm">{formatDateTime(user?.registeredAt || user?.createdAt)}</p>
</div>
</div>
{/* 认种统计 */}
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<p className="text-sm text-muted-foreground flex items-center gap-1">
<TreePine className="h-3 w-3 text-green-600" />
</p>
<p className="text-lg font-bold text-green-600">{formatNumber(user?.personalAdoptions ?? 0)}</p>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-lg">
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3 text-blue-600" />
</p>
<p className="text-lg font-bold text-blue-600">{formatNumber(user?.teamAdoptions ?? 0)}</p>
</div>
<div className="p-3 bg-purple-50 dark:bg-purple-950 rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-bold text-purple-600">{formatNumber(user?.teamAddresses ?? 0)}</p>
</div>
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-bold text-amber-600">
{user?.ranking ? `#${user.ranking}` : '-'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* 算力构成卡片 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
@ -128,45 +201,102 @@ export default function UserDetailPage() {
<span className="font-mono">{formatDecimal(user?.contributions?.teamBonus, 4)}</span>
</div>
<div className="flex justify-between pt-3 border-t font-medium">
<span></span>
<span className="font-mono text-primary">{formatDecimal(user?.effectiveContribution, 4)}</span>
<span> ()</span>
<span className="font-mono text-primary text-lg">{formatDecimal(user?.effectiveContribution, 4)}</span>
</div>
</CardContent>
</Card>
</div>
{/* 余额卡片 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6 text-center">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold font-mono text-primary">{formatDecimal(user?.miningBalance, 4)}</p>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Coins className="h-4 w-4" />
</p>
<p className="text-3xl font-bold font-mono text-primary mt-1">
{formatDecimal(user?.miningBalance, 4)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold font-mono text-blue-600">{formatDecimal(user?.tradingBalance, 4)}</p>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<ShoppingCart className="h-4 w-4" />
</p>
<p className="text-3xl font-bold font-mono text-blue-600 mt-1">
{formatDecimal(user?.tradingBalance, 4)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold font-mono text-orange-600">{formatDecimal(user?.frozenBalance, 4)}</p>
<p className="text-3xl font-bold font-mono text-orange-600 mt-1">
{formatDecimal(user?.frozenBalance, 4)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Tab 区域 */}
<Tabs defaultValue="contributions">
<TabsList>
<TabsTrigger value="contributions"></TabsTrigger>
<TabsTrigger value="mining"></TabsTrigger>
<TabsTrigger value="trading"></TabsTrigger>
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="contributions" className="flex items-center gap-1">
<Zap className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="referral" className="flex items-center gap-1">
<Network className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="planting" className="flex items-center gap-1">
<TreePine className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="wallet" className="flex items-center gap-1">
<Wallet className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="mining" className="flex items-center gap-1">
<Coins className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="trading" className="flex items-center gap-1">
<ShoppingCart className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
</TabsList>
<TabsContent value="contributions" className="mt-4">
<ContributionRecordsList accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="referral" className="mt-4">
<ReferralTree accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="planting" className="mt-4">
<PlantingLedger accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="wallet" className="mt-4">
<WalletLedger accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="mining" className="mt-4">
<MiningRecordsList accountSequence={accountSequence} />
</TabsContent>

View File

@ -2,6 +2,7 @@
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { PageHeader } from '@/components/layout/page-header';
import { useUsers } from '@/features/users/hooks/use-users';
import { formatDecimal, formatNumber } from '@/lib/utils/format';
@ -10,8 +11,10 @@ import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Search, ChevronLeft, ChevronRight, Eye } from 'lucide-react';
import { Search, ChevronLeft, ChevronRight, Eye, Users, TreePine } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
export default function UsersPage() {
const [keyword, setKeyword] = useState('');
@ -32,9 +35,23 @@ export default function UsersPage() {
}
};
// 获取状态徽章样式
const getStatusBadge = (status?: string, isOnline?: boolean) => {
if (status === 'frozen') {
return <Badge variant="destructive" className="text-xs"></Badge>;
}
if (status === 'deactivated') {
return <Badge variant="secondary" className="text-xs"></Badge>;
}
if (isOnline) {
return <Badge className="bg-green-500 text-xs">线</Badge>;
}
return null;
};
return (
<div className="space-y-6">
<PageHeader title="用户管理" description="查看和管理用户的算力、积分股等信息" />
<PageHeader title="用户管理" description="查看和管理用户的算力、贡献值、认种等信息" />
<Card>
<CardContent className="p-4">
@ -56,16 +73,20 @@ export default function UsersPage() {
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
@ -74,7 +95,7 @@ export default function UsersPage() {
{isLoading ? (
[...Array(10)].map((_, i) => (
<TableRow key={i}>
{[...Array(9)].map((_, j) => (
{[...Array(12)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
@ -83,17 +104,40 @@ export default function UsersPage() {
))
) : data?.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
data?.items.map((user) => (
<TableRow key={user.accountSequence}>
<TableCell className="font-mono">{user.accountSequence}</TableCell>
<TableCell>{user.nickname || '-'}</TableCell>
<TableCell>{user.phone}</TableCell>
{/* 头像 */}
<TableCell>
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar || undefined} alt={user.nickname || ''} />
<AvatarFallback className="text-xs">
{user.nickname?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
{user.isOnline && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white" />
)}
</div>
</TableCell>
{/* 账户序列 */}
<TableCell className="font-mono text-sm">{user.accountSequence}</TableCell>
{/* 昵称 */}
<TableCell>
<div className="flex items-center gap-2">
<span className="max-w-[120px] truncate">{user.nickname || '-'}</span>
{getStatusBadge(user.status, user.isOnline)}
</div>
</TableCell>
{/* 手机号 */}
<TableCell className="text-sm">{user.phoneNumberMasked || user.phone}</TableCell>
{/* 认种状态 */}
<TableCell className="text-center">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.hasAdopted ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
@ -102,12 +146,41 @@ export default function UsersPage() {
{user.hasAdopted ? '已认种' : '未认种'}
</span>
</TableCell>
<TableCell className="text-right font-mono">
{/* 个人认种 */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TreePine className="h-3 w-3 text-green-600" />
<span className="font-mono text-sm">
{formatNumber(user.personalAdoptions ?? 0)}
</span>
</div>
</TableCell>
{/* 团队认种 */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Users className="h-3 w-3 text-blue-600" />
<span className="font-mono text-sm">
{formatNumber(user.teamAdoptions ?? 0)}
</span>
</div>
</TableCell>
{/* 有效算力 */}
<TableCell className="text-right font-mono text-sm text-primary font-medium">
{formatDecimal(user.effectiveContribution, 4)}
</TableCell>
<TableCell className="text-right font-mono">{formatDecimal(user.miningBalance, 4)}</TableCell>
<TableCell className="text-right font-mono">{formatDecimal(user.tradingBalance, 4)}</TableCell>
<TableCell className="text-sm">{formatDateTime(user.createdAt)}</TableCell>
{/* 挖矿余额 */}
<TableCell className="text-right font-mono text-sm">
{formatDecimal(user.miningBalance, 4)}
</TableCell>
{/* 推荐人 */}
<TableCell className="font-mono text-sm text-muted-foreground">
{user.referrerId || user.referrerAccountSequence || '-'}
</TableCell>
{/* 注册时间 */}
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(user.createdAt)}
</TableCell>
{/* 操作 */}
<TableCell>
<Link href={`/users/${user.accountSequence}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
@ -120,6 +193,7 @@ export default function UsersPage() {
)}
</TableBody>
</Table>
</div>
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">

View File

@ -1,5 +1,14 @@
import { apiClient } from '@/lib/api/client';
import type { UserOverview, UserDetail, ContributionRecord, MiningRecord, TradeOrder } from '@/types/user';
import type {
UserOverview,
UserDetail,
ContributionRecord,
MiningRecord,
TradeOrder,
ReferralTreeData,
PlantingLedgerResponse,
WalletLedgerResponse,
} from '@/types/user';
import type { PaginatedResponse, PaginationParams } from '@/types/api';
export const usersApi = {
@ -36,4 +45,32 @@ export const usersApi = {
const response = await apiClient.get(`/users/${accountSequence}/orders`, { params });
return response.data.data;
},
// 从 admin-web 复用的 API
getReferralTree: async (
accountSequence: string,
direction: 'up' | 'down' | 'both' = 'both',
depth: number = 1
): Promise<ReferralTreeData> => {
const response = await apiClient.get(`/users/${accountSequence}/referral-tree`, {
params: { direction, depth },
});
return response.data.data;
},
getPlantingLedger: async (
accountSequence: string,
params: PaginationParams
): Promise<PlantingLedgerResponse> => {
const response = await apiClient.get(`/users/${accountSequence}/planting-ledger`, { params });
return response.data.data;
},
getWalletLedger: async (
accountSequence: string,
params: PaginationParams
): Promise<WalletLedgerResponse> => {
const response = await apiClient.get(`/users/${accountSequence}/wallet-ledger`, { params });
return response.data.data;
},
};

View File

@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { usePlantingLedger } from '../hooks/use-users';
import { formatDecimal, formatNumber } from '@/lib/utils/format';
import { formatDateTime } from '@/lib/utils/date';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, TreePine, Calendar, DollarSign } from 'lucide-react';
interface PlantingLedgerProps {
accountSequence: string;
}
// 认种状态标签
const plantingStatusLabels: Record<string, string> = {
CREATED: '已创建',
PAID: '已支付',
FUND_ALLOCATED: '资金已分配',
MINING_ENABLED: '已开始挖矿',
CANCELLED: '已取消',
EXPIRED: '已过期',
};
// 状态对应的样式
const getStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (status) {
case 'MINING_ENABLED':
case 'PAID':
case 'FUND_ALLOCATED':
return 'default';
case 'CREATED':
return 'secondary';
case 'CANCELLED':
case 'EXPIRED':
return 'destructive';
default:
return 'outline';
}
};
export function PlantingLedger({ accountSequence }: PlantingLedgerProps) {
const [page, setPage] = useState(1);
const pageSize = 10;
const { data, isLoading } = usePlantingLedger(accountSequence, { page, pageSize });
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
if (!data) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8"></p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* 认种汇总 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<TreePine className="h-5 w-5 text-green-600" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatNumber(data.summary.totalOrders)}</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600">{formatNumber(data.summary.totalTreeCount)}</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-primary">{formatDecimal(data.summary.totalAmount, 2)}</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-blue-600">{formatNumber(data.summary.effectiveTreeCount)}</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm font-medium">
{data.summary.firstPlantingAt ? formatDateTime(data.summary.firstPlantingAt) : '-'}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm font-medium">
{data.summary.lastPlantingAt ? formatDateTime(data.summary.lastPlantingAt) : '-'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* 认种分类账明细 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((item) => (
<TableRow key={item.orderId}>
<TableCell className="font-mono text-sm">{item.orderNo}</TableCell>
<TableCell className="text-right font-mono">{formatNumber(item.treeCount)}</TableCell>
<TableCell className="text-right font-mono">{formatDecimal(item.totalAmount, 2)}</TableCell>
<TableCell className="text-sm">
{item.selectedProvince || '-'} / {item.selectedCity || '-'}
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(item.status)}>
{plantingStatusLabels[item.status] || item.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(item.createdAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.paidAt ? formatDateTime(item.paidAt) : '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{data.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
<p className="text-sm text-muted-foreground">
{formatNumber(data.total)} {page} / {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(page - 1)} disabled={page <= 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,289 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import Link from 'next/link';
import { useReferralTree } from '../hooks/use-users';
import { usersApi } from '../api/users.api';
import { formatNumber } from '@/lib/utils/format';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ChevronDown, ChevronRight, Users, TreePine, Building2 } from 'lucide-react';
import type { ReferralNode } from '@/types/user';
import { cn } from '@/lib/utils';
interface ReferralTreeProps {
accountSequence: string;
}
export function ReferralTree({ accountSequence }: ReferralTreeProps) {
const [treeRootUser, setTreeRootUser] = useState<string>(accountSequence);
const [expandedNodes, setExpandedNodes] = useState<Record<string, ReferralNode[] | null>>({});
const { data: referralTree, isLoading } = useReferralTree(treeRootUser, 'both', 1);
// 当 referralTree 数据加载完成后,自动展开当前用户的直推下级
useEffect(() => {
if (referralTree && referralTree.directReferrals.length > 0) {
setExpandedNodes((prev) => ({
...prev,
[referralTree.currentUser.accountSequence]: referralTree.directReferrals,
}));
}
}, [referralTree]);
// 切换推荐关系树的根节点
const handleTreeNodeClick = useCallback((node: ReferralNode) => {
setTreeRootUser(node.accountSequence);
}, []);
// 展开/收起节点的下级
const handleToggleNode = useCallback(async (nodeSeq: string, hasChildren: boolean) => {
if (!hasChildren) return;
// 如果已展开,则收起
if (expandedNodes[nodeSeq] !== undefined) {
setExpandedNodes((prev) => {
const newState = { ...prev };
delete newState[nodeSeq];
return newState;
});
return;
}
// 展开:先标记为 null加载中然后获取数据
setExpandedNodes((prev) => ({ ...prev, [nodeSeq]: null }));
try {
const treeData = await usersApi.getReferralTree(nodeSeq, 'down', 1);
setExpandedNodes((prev) => ({ ...prev, [nodeSeq]: treeData.directReferrals }));
} catch (error) {
console.error('获取下级失败:', error);
setExpandedNodes((prev) => {
const newState = { ...prev };
delete newState[nodeSeq];
return newState;
});
}
}, [expandedNodes]);
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (!referralTree) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8"></p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
{treeRootUser !== accountSequence && (
<Button variant="outline" size="sm" onClick={() => setTreeRootUser(accountSequence)}>
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* 向上的引荐人链 */}
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"> ()</p>
{referralTree.ancestors.length > 0 ? (
<div className="space-y-2">
{referralTree.ancestors.map((ancestor, index) => (
<div key={ancestor.accountSequence}>
<ReferralNodeCard
node={ancestor}
onClick={() => handleTreeNodeClick(ancestor)}
variant="ancestor"
/>
{index < referralTree.ancestors.length - 1 && (
<div className="flex justify-center py-1">
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
))}
<div className="flex justify-center py-1">
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-center gap-2 p-3 bg-muted rounded-lg">
<Building2 className="h-4 w-4" />
<span className="text-sm font-medium"></span>
</div>
)}
</div>
{/* 当前用户 */}
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"></p>
<ReferralNodeCard
node={referralTree.currentUser}
isCurrentUser
isHighlight={referralTree.currentUser.accountSequence === accountSequence}
expandedNodes={expandedNodes}
onToggle={handleToggleNode}
onClick={handleTreeNodeClick}
/>
</div>
{/* 直推下级列表 */}
{referralTree.directReferrals.length > 0 && (
<div className="space-y-2 ml-6 border-l-2 border-muted pl-4">
<p className="text-sm font-medium text-muted-foreground">
({referralTree.directReferrals.length})
</p>
<div className="space-y-2">
{referralTree.directReferrals.map((child) => (
<ReferralNodeCard
key={child.accountSequence}
node={child}
expandedNodes={expandedNodes}
onToggle={handleToggleNode}
onClick={handleTreeNodeClick}
showExpandButton
/>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ReferralNodeCardProps {
node: ReferralNode;
isCurrentUser?: boolean;
isHighlight?: boolean;
variant?: 'ancestor' | 'current' | 'child';
expandedNodes?: Record<string, ReferralNode[] | null>;
onToggle?: (nodeSeq: string, hasChildren: boolean) => void;
onClick?: (node: ReferralNode) => void;
showExpandButton?: boolean;
}
function ReferralNodeCard({
node,
isCurrentUser = false,
isHighlight = false,
variant,
expandedNodes = {},
onToggle,
onClick,
showExpandButton = false,
}: ReferralNodeCardProps) {
const hasChildren = node.directReferralCount > 0;
const isExpanded = expandedNodes[node.accountSequence] !== undefined;
const isLoading = expandedNodes[node.accountSequence] === null;
const children = expandedNodes[node.accountSequence] || [];
return (
<div className="space-y-2">
<div
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-accent',
isCurrentUser && 'border-primary bg-primary/5',
isHighlight && 'ring-2 ring-primary'
)}
onClick={() => onClick?.(node)}
>
<Avatar className="h-10 w-10">
<AvatarImage src={node.avatar || undefined} alt={node.nickname || ''} />
<AvatarFallback>{node.nickname?.charAt(0) || 'U'}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{node.accountSequence}</span>
<span className="text-sm truncate">{node.nickname || '未设置'}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<TreePine className="h-3 w-3 text-green-600" />
: {formatNumber(node.personalAdoptions)}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3 text-blue-600" />
: {formatNumber(node.teamAdoptions)}
</span>
{node.directReferralCount > 0 && (
<span>: {formatNumber(node.directReferralCount)}</span>
)}
</div>
</div>
{showExpandButton && hasChildren && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onToggle?.(node.accountSequence, hasChildren);
}}
disabled={isLoading}
>
{isLoading ? (
<span className="animate-spin">...</span>
) : isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
)}
<Link
href={`/users/${node.accountSequence}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
</Link>
</div>
{/* 展开的子节点 */}
{isExpanded && children.length > 0 && (
<div className="ml-6 border-l-2 border-muted pl-4 space-y-2">
{children.map((child) => (
<ReferralNodeCard
key={child.accountSequence}
node={child}
expandedNodes={expandedNodes}
onToggle={onToggle}
onClick={onClick}
showExpandButton
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,251 @@
'use client';
import { useState } from 'react';
import { useWalletLedger } from '../hooks/use-users';
import { formatDecimal, formatNumber } from '@/lib/utils/format';
import { formatDateTime } from '@/lib/utils/date';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { ChevronLeft, ChevronRight, Wallet, TrendingUp, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface WalletLedgerProps {
accountSequence: string;
}
// 流水类型标签
const entryTypeLabels: Record<string, string> = {
DEPOSIT: '充值',
DEPOSIT_USDT: 'USDT充值',
DEPOSIT_BNB: 'BNB充值',
WITHDRAW: '提现',
WITHDRAW_FROZEN: '提现冻结',
WITHDRAW_CONFIRMED: '提现确认',
WITHDRAW_CANCELLED: '提现取消',
PLANTING_PAYMENT: '认种支付',
PLANTING_FROZEN: '认种冻结',
PLANTING_DEDUCT: '认种扣款',
REWARD_PENDING: '收益待领取',
REWARD_SETTLED: '收益结算',
REWARD_EXPIRED: '收益过期',
TRANSFER_OUT: '转出',
TRANSFER_IN: '转入',
INTERNAL_TRANSFER: '内部转账',
ADMIN_ADJUSTMENT: '管理员调整',
SYSTEM_DEDUCT: '系统扣款',
FEE: '手续费',
MINING_REWARD: '挖矿奖励',
TRADE_BUY: '买入',
TRADE_SELL: '卖出',
};
// 资产类型标签
const assetTypeLabels: Record<string, string> = {
USDT: '绿积分',
DST: 'DST',
BNB: 'BNB',
OG: 'OG',
RWAD: 'RWAD',
HASHPOWER: '算力',
MINING: '挖矿积分',
TRADING: '交易积分',
};
export function WalletLedger({ accountSequence }: WalletLedgerProps) {
const [page, setPage] = useState(1);
const pageSize = 10;
const { data, isLoading } = useWalletLedger(accountSequence, { page, pageSize });
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
if (!data) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8"></p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* 钱包汇总 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Wallet className="h-5 w-5 text-blue-600" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">绿 </p>
<p className="text-2xl font-bold text-green-600 font-mono">
{formatDecimal(data.summary.usdtAvailable, 4)}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">绿 </p>
<p className="text-2xl font-bold text-orange-600 font-mono">
{formatDecimal(data.summary.usdtFrozen, 4)}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-yellow-600 font-mono">
{formatDecimal(data.summary.pendingUsdt, 4)}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-blue-600 font-mono">
{formatDecimal(data.summary.settleableUsdt, 4)}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-primary font-mono">
{formatDecimal(data.summary.settledTotalUsdt, 4)}
</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-muted-foreground font-mono">
{formatDecimal(data.summary.expiredTotalUsdt, 4)}
</p>
</div>
</div>
</CardContent>
</Card>
{/* 钱包分类账明细 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((item) => {
const amount = parseFloat(item.amount);
const isPositive = amount >= 0;
return (
<TableRow key={item.entryId}>
<TableCell className="font-mono text-xs">{item.entryId}</TableCell>
<TableCell className="text-sm">
{entryTypeLabels[item.entryType] || item.entryType}
</TableCell>
<TableCell className="text-sm">
{assetTypeLabels[item.assetType] || item.assetType}
</TableCell>
<TableCell className="text-right">
<span
className={cn(
'font-mono flex items-center justify-end gap-1',
isPositive ? 'text-green-600' : 'text-red-600'
)}
>
{isPositive ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{isPositive ? '+' : ''}
{formatDecimal(item.amount, 4)}
</span>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatDecimal(item.balanceAfter, 4)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground max-w-[120px] truncate">
{item.refOrderId || item.refTxHash || '-'}
</TableCell>
<TableCell className="text-sm max-w-[100px] truncate" title={item.memo}>
{item.memo || '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(item.createdAt)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{data.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
<p className="text-sm text-muted-foreground">
{formatNumber(data.total)} {page} / {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(page - 1)} disabled={page <= 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -40,3 +40,32 @@ export function useTradeOrders(accountSequence: string, params: PaginationParams
enabled: !!accountSequence,
});
}
// 从 admin-web 复用的 hooks
export function useReferralTree(
accountSequence: string,
direction: 'up' | 'down' | 'both' = 'both',
depth: number = 1
) {
return useQuery({
queryKey: ['users', accountSequence, 'referral-tree', direction, depth],
queryFn: () => usersApi.getReferralTree(accountSequence, direction, depth),
enabled: !!accountSequence,
});
}
export function usePlantingLedger(accountSequence: string, params: PaginationParams) {
return useQuery({
queryKey: ['users', accountSequence, 'planting-ledger', params],
queryFn: () => usersApi.getPlantingLedger(accountSequence, params),
enabled: !!accountSequence,
});
}
export function useWalletLedger(accountSequence: string, params: PaginationParams) {
return useQuery({
queryKey: ['users', accountSequence, 'wallet-ledger', params],
queryFn: () => usersApi.getWalletLedger(accountSequence, params),
enabled: !!accountSequence,
});
}

View File

@ -1,13 +1,32 @@
export interface UserOverview {
accountSequence: number;
accountId?: string;
nickname: string;
phone: string;
phoneNumberMasked?: string;
avatar?: string | null;
hasAdopted: boolean;
totalContribution: string;
effectiveContribution: string;
miningBalance: string;
tradingBalance: string;
frozenBalance: string;
// 从 admin-web 复用的字段
personalAdoptions?: number;
teamAdoptions?: number;
teamAddresses?: number;
provincialAdoptions?: {
count: number;
percentage: number;
};
cityAdoptions?: {
count: number;
percentage: number;
};
referrerId?: string | null;
ranking?: number | null;
status?: 'active' | 'frozen' | 'deactivated';
isOnline?: boolean;
createdAt: string;
}
@ -18,6 +37,17 @@ export interface UserDetail extends UserOverview {
teamSize: number;
teamAdoptedCount: number;
contributions: ContributionBreakdown;
// 从 admin-web 复用的字段
kycStatus?: string;
registeredAt?: string;
lastActiveAt?: string;
referralInfo?: {
referrerSequence?: string;
referrerNickname?: string;
usedReferralCode?: string;
depth?: number;
directReferralCount?: number;
};
}
export interface ContributionBreakdown {
@ -65,3 +95,87 @@ export interface TradeOrder {
createdAt: string;
updatedAt: string;
}
// 引荐关系节点
export interface ReferralNode {
accountSequence: string;
nickname: string | null;
avatar?: string | null;
personalAdoptions: number;
teamAdoptions: number;
directReferralCount: number;
isOnline?: boolean;
}
// 引荐关系树
export interface ReferralTreeData {
currentUser: ReferralNode;
ancestors: ReferralNode[];
directReferrals: ReferralNode[];
}
// 认种订单
export interface PlantingOrder {
orderId: string;
orderNo: string;
treeCount: number;
totalAmount: string;
selectedProvince?: string;
selectedCity?: string;
status: string;
createdAt: string;
paidAt?: string;
}
// 认种汇总
export interface PlantingSummary {
totalOrders: number;
totalTreeCount: number;
totalAmount: string;
effectiveTreeCount: number;
firstPlantingAt?: string;
lastPlantingAt?: string;
}
// 认种分类账响应
export interface PlantingLedgerResponse {
summary: PlantingSummary;
items: PlantingOrder[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// 钱包流水
export interface WalletLedgerItem {
entryId: string;
entryType: string;
assetType: string;
amount: string;
balanceAfter: string;
refOrderId?: string;
refTxHash?: string;
memo?: string;
createdAt: string;
}
// 钱包汇总
export interface WalletSummary {
usdtAvailable: string;
usdtFrozen: string;
pendingUsdt: string;
settleableUsdt: string;
settledTotalUsdt: string;
expiredTotalUsdt: string;
}
// 钱包分类账响应
export interface WalletLedgerResponse {
summary: WalletSummary;
items: WalletLedgerItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}