feat(admin): 实现用户详情页面

前端 (admin-web):
- 新增用户详情页面 /users/[id]
- 实现推荐关系树可视化,支持点击节点切换视角
- 添加认种分类账Tab,显示汇总和订单明细
- 添加钱包分类账Tab,显示余额汇总和流水明细
- 添加授权信息Tab,显示角色、月度考核和系统账户流水
- 用户列表"查看详情"改为 Link 导航到详情页

后端 (admin-service):
- 新增 UserDetailController 提供详情页API
- 新增 UserDetailQueryRepository 查询CDC同步的数据
- API: GET /admin/users/:seq/full-detail
- API: GET /admin/users/:seq/referral-tree
- API: GET /admin/users/:seq/planting-ledger
- API: GET /admin/users/:seq/wallet-ledger
- API: GET /admin/users/:seq/authorization-detail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-07 20:10:01 -08:00
parent d293ec10e4
commit 3d31e8beb9
15 changed files with 3904 additions and 82 deletions

View File

@ -0,0 +1,402 @@
import {
Controller,
Get,
Param,
Query,
HttpCode,
HttpStatus,
NotFoundException,
Inject,
} from '@nestjs/common';
import { ReferralTreeQueryDto, LedgerQueryDto, WalletLedgerQueryDto } from '../dto/request/user-detail-query.dto';
import {
UserFullDetailDto,
ReferralTreeDto,
ReferralNodeDto,
PlantingLedgerResponseDto,
WalletLedgerResponseDto,
AuthorizationDetailResponseDto,
} from '../dto/response/user-detail.dto';
import {
IUserQueryRepository,
USER_QUERY_REPOSITORY,
} from '../../domain/repositories/user-query.repository';
import {
IUserDetailQueryRepository,
USER_DETAIL_QUERY_REPOSITORY,
} from '../../domain/repositories/user-detail-query.repository';
/**
*
* admin-web API
*/
@Controller('admin/users')
export class UserDetailController {
constructor(
@Inject(USER_QUERY_REPOSITORY)
private readonly userQueryRepository: IUserQueryRepository,
@Inject(USER_DETAIL_QUERY_REPOSITORY)
private readonly userDetailRepository: IUserDetailQueryRepository,
) {}
/**
*
* GET /admin/users/:accountSequence/full-detail
*/
@Get(':accountSequence/full-detail')
@HttpCode(HttpStatus.OK)
async getFullDetail(
@Param('accountSequence') accountSequence: string,
): Promise<UserFullDetailDto> {
// 获取基本用户信息
const user = await this.userQueryRepository.findByAccountSequence(accountSequence);
if (!user) {
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
}
// 获取推荐关系信息
const referralInfo = await this.userDetailRepository.getReferralInfo(accountSequence);
// 获取推荐人昵称
let referrerNickname: string | null = null;
let referrerSequence: string | null = null;
if (referralInfo?.referrerId) {
referrerNickname = await this.userDetailRepository.getReferrerNickname(referralInfo.referrerId);
// 获取推荐人的 accountSequence
const referrerUser = await this.userQueryRepository.findById(referralInfo.referrerId);
referrerSequence = referrerUser?.accountSequence || null;
}
return {
accountId: user.userId.toString(),
accountSequence: user.accountSequence,
avatar: user.avatarUrl,
nickname: user.nickname,
phoneNumberMasked: user.phoneNumberMasked,
status: this.mapStatus(user.status),
kycStatus: user.kycStatus,
isOnline: user.isOnline,
registeredAt: user.registeredAt.toISOString(),
lastActiveAt: user.lastActiveAt?.toISOString() || null,
personalAdoptions: user.personalAdoptionCount,
teamAddresses: user.teamAddressCount,
teamAdoptions: user.teamAdoptionCount,
provincialAdoptions: {
count: user.provinceAdoptionCount,
percentage: user.teamAdoptionCount > 0
? Math.round((user.provinceAdoptionCount / user.teamAdoptionCount) * 100)
: 0,
},
cityAdoptions: {
count: user.cityAdoptionCount,
percentage: user.teamAdoptionCount > 0
? Math.round((user.cityAdoptionCount / user.teamAdoptionCount) * 100)
: 0,
},
ranking: user.leaderboardRank,
referralInfo: {
myReferralCode: referralInfo?.myReferralCode || '',
usedReferralCode: referralInfo?.usedReferralCode || null,
referrerId: referralInfo?.referrerId?.toString() || null,
referrerSequence,
referrerNickname,
ancestorPath: referralInfo?.ancestorPath?.map((id) => id.toString()).join(',') || null,
depth: referralInfo?.depth || 0,
directReferralCount: referralInfo?.directReferralCount || 0,
activeDirectCount: referralInfo?.activeDirectCount || 0,
},
};
}
/**
*
* GET /admin/users/:accountSequence/referral-tree
*/
@Get(':accountSequence/referral-tree')
@HttpCode(HttpStatus.OK)
async getReferralTree(
@Param('accountSequence') accountSequence: string,
@Query() query: ReferralTreeQueryDto,
): Promise<ReferralTreeDto> {
// 获取当前用户信息
const user = await this.userQueryRepository.findByAccountSequence(accountSequence);
if (!user) {
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
}
const referralInfo = await this.userDetailRepository.getReferralInfo(accountSequence);
const currentUser: ReferralNodeDto = {
accountSequence: user.accountSequence,
userId: user.userId.toString(),
nickname: user.nickname,
avatar: user.avatarUrl,
personalAdoptions: user.personalAdoptionCount,
depth: referralInfo?.depth || 0,
directReferralCount: referralInfo?.directReferralCount || 0,
isCurrentUser: true,
};
let ancestors: ReferralNodeDto[] = [];
let directReferrals: ReferralNodeDto[] = [];
// 向上查询
if (query.direction === 'up' || query.direction === 'both') {
const ancestorNodes = await this.userDetailRepository.getAncestors(
accountSequence,
query.depth || 1,
);
ancestors = ancestorNodes.map((node) => ({
accountSequence: node.accountSequence,
userId: node.userId.toString(),
nickname: node.nickname,
avatar: node.avatarUrl,
personalAdoptions: node.personalAdoptionCount,
depth: node.depth,
directReferralCount: node.directReferralCount,
}));
}
// 向下查询
if (query.direction === 'down' || query.direction === 'both') {
const referralNodes = await this.userDetailRepository.getDirectReferrals(accountSequence);
directReferrals = referralNodes.map((node) => ({
accountSequence: node.accountSequence,
userId: node.userId.toString(),
nickname: node.nickname,
avatar: node.avatarUrl,
personalAdoptions: node.personalAdoptionCount,
depth: node.depth,
directReferralCount: node.directReferralCount,
}));
}
return {
currentUser,
ancestors,
directReferrals,
};
}
/**
*
* GET /admin/users/:accountSequence/planting-ledger
*/
@Get(':accountSequence/planting-ledger')
@HttpCode(HttpStatus.OK)
async getPlantingLedger(
@Param('accountSequence') accountSequence: string,
@Query() query: LedgerQueryDto,
): Promise<PlantingLedgerResponseDto> {
const user = await this.userQueryRepository.findByAccountSequence(accountSequence);
if (!user) {
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
}
const [summary, ledger] = await Promise.all([
this.userDetailRepository.getPlantingSummary(user.userId),
this.userDetailRepository.getPlantingLedger(
user.userId,
query.page || 1,
query.pageSize || 20,
query.startDate ? new Date(query.startDate) : undefined,
query.endDate ? new Date(query.endDate) : undefined,
),
]);
return {
summary: {
totalOrders: summary?.totalOrders || 0,
totalTreeCount: summary?.totalTreeCount || 0,
totalAmount: this.formatDecimal(summary?.totalAmount),
effectiveTreeCount: summary?.effectiveTreeCount || 0,
pendingTreeCount: summary?.pendingTreeCount || 0,
firstPlantingAt: summary?.firstPlantingAt?.toISOString() || null,
lastPlantingAt: summary?.lastPlantingAt?.toISOString() || null,
},
items: ledger.items.map((item) => ({
orderId: item.orderId.toString(),
orderNo: item.orderNo,
treeCount: item.treeCount,
totalAmount: this.formatDecimal(item.totalAmount),
status: item.status,
selectedProvince: item.selectedProvince,
selectedCity: item.selectedCity,
createdAt: item.createdAt.toISOString(),
paidAt: item.paidAt?.toISOString() || null,
fundAllocatedAt: item.fundAllocatedAt?.toISOString() || null,
miningEnabledAt: item.miningEnabledAt?.toISOString() || null,
})),
total: ledger.total,
page: ledger.page,
pageSize: ledger.pageSize,
totalPages: ledger.totalPages,
};
}
/**
*
* GET /admin/users/:accountSequence/wallet-ledger
*/
@Get(':accountSequence/wallet-ledger')
@HttpCode(HttpStatus.OK)
async getWalletLedger(
@Param('accountSequence') accountSequence: string,
@Query() query: WalletLedgerQueryDto,
): Promise<WalletLedgerResponseDto> {
const user = await this.userQueryRepository.findByAccountSequence(accountSequence);
if (!user) {
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
}
const [summary, ledger] = await Promise.all([
this.userDetailRepository.getWalletSummary(user.userId),
this.userDetailRepository.getWalletLedger(
user.userId,
query.page || 1,
query.pageSize || 20,
{
assetType: query.assetType,
entryType: query.entryType,
startDate: query.startDate ? new Date(query.startDate) : undefined,
endDate: query.endDate ? new Date(query.endDate) : undefined,
},
),
]);
return {
summary: {
usdtAvailable: this.formatDecimal(summary?.usdtAvailable),
usdtFrozen: this.formatDecimal(summary?.usdtFrozen),
dstAvailable: this.formatDecimal(summary?.dstAvailable),
dstFrozen: this.formatDecimal(summary?.dstFrozen),
bnbAvailable: this.formatDecimal(summary?.bnbAvailable),
bnbFrozen: this.formatDecimal(summary?.bnbFrozen),
ogAvailable: this.formatDecimal(summary?.ogAvailable),
ogFrozen: this.formatDecimal(summary?.ogFrozen),
rwadAvailable: this.formatDecimal(summary?.rwadAvailable),
rwadFrozen: this.formatDecimal(summary?.rwadFrozen),
hashpower: this.formatDecimal(summary?.hashpower),
pendingUsdt: this.formatDecimal(summary?.pendingUsdt),
pendingHashpower: this.formatDecimal(summary?.pendingHashpower),
settleableUsdt: this.formatDecimal(summary?.settleableUsdt),
settleableHashpower: this.formatDecimal(summary?.settleableHashpower),
settledTotalUsdt: this.formatDecimal(summary?.settledTotalUsdt),
settledTotalHashpower: this.formatDecimal(summary?.settledTotalHashpower),
expiredTotalUsdt: this.formatDecimal(summary?.expiredTotalUsdt),
expiredTotalHashpower: this.formatDecimal(summary?.expiredTotalHashpower),
},
items: ledger.items.map((item) => ({
entryId: item.entryId.toString(),
entryType: item.entryType,
assetType: item.assetType,
amount: this.formatDecimal(item.amount),
balanceAfter: item.balanceAfter ? this.formatDecimal(item.balanceAfter) : null,
refOrderId: item.refOrderId,
refTxHash: item.refTxHash,
memo: item.memo,
createdAt: item.createdAt.toISOString(),
})),
total: ledger.total,
page: ledger.page,
pageSize: ledger.pageSize,
totalPages: ledger.totalPages,
};
}
/**
*
* GET /admin/users/:accountSequence/authorization-detail
*/
@Get(':accountSequence/authorization-detail')
@HttpCode(HttpStatus.OK)
async getAuthorizationDetail(
@Param('accountSequence') accountSequence: string,
): Promise<AuthorizationDetailResponseDto> {
const user = await this.userQueryRepository.findByAccountSequence(accountSequence);
if (!user) {
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
}
const [roles, assessments, systemLedger] = await Promise.all([
this.userDetailRepository.getAuthorizationRoles(accountSequence),
this.userDetailRepository.getMonthlyAssessments(accountSequence),
this.userDetailRepository.getSystemAccountLedger(accountSequence),
]);
return {
roles: roles.map((role) => ({
id: role.id,
roleType: role.roleType,
regionCode: role.regionCode,
regionName: role.regionName,
displayTitle: role.displayTitle,
status: role.status,
benefitActive: role.benefitActive,
benefitActivatedAt: role.benefitActivatedAt?.toISOString() || null,
authorizedAt: role.authorizedAt?.toISOString() || null,
authorizedBy: role.authorizedBy,
initialTargetTreeCount: role.initialTargetTreeCount,
monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded,
createdAt: role.createdAt.toISOString(),
})),
assessments: assessments.map((assessment) => ({
id: assessment.id,
authorizationId: assessment.authorizationId,
roleType: assessment.roleType,
regionCode: assessment.regionCode,
assessmentMonth: assessment.assessmentMonth,
monthIndex: assessment.monthIndex,
monthlyTarget: assessment.monthlyTarget,
monthlyCompleted: assessment.monthlyCompleted,
cumulativeTarget: assessment.cumulativeTarget,
cumulativeCompleted: assessment.cumulativeCompleted,
result: assessment.result,
rankingInRegion: assessment.rankingInRegion,
isFirstPlace: assessment.isFirstPlace,
isBypassed: assessment.isBypassed,
completedAt: assessment.completedAt?.toISOString() || null,
assessedAt: assessment.assessedAt?.toISOString() || null,
})),
systemAccountLedger: systemLedger.map((ledger) => ({
ledgerId: ledger.ledgerId.toString(),
accountId: ledger.accountId.toString(),
accountType: ledger.accountType,
entryType: ledger.entryType,
amount: this.formatDecimal(ledger.amount),
balanceAfter: this.formatDecimal(ledger.balanceAfter),
sourceOrderId: ledger.sourceOrderId?.toString() || null,
sourceRewardId: ledger.sourceRewardId?.toString() || null,
txHash: ledger.txHash,
memo: ledger.memo,
createdAt: ledger.createdAt.toISOString(),
})),
};
}
// ============================================================================
// 辅助方法
// ============================================================================
private mapStatus(status: string): 'active' | 'frozen' | 'deactivated' {
switch (status.toUpperCase()) {
case 'ACTIVE':
return 'active';
case 'FROZEN':
return 'frozen';
case 'DEACTIVATED':
return 'deactivated';
default:
return 'active';
}
}
private formatDecimal(value: bigint | null | undefined): string {
if (!value) return '0';
// bigint 已经乘以 1e8需要转回小数
const num = Number(value) / 1e8;
return num.toFixed(8).replace(/\.?0+$/, '');
}
}

View File

@ -0,0 +1,57 @@
import { IsOptional, IsInt, Min, Max, IsIn, IsString, IsDateString } from 'class-validator';
import { Transform } from 'class-transformer';
/**
*
*/
export class ReferralTreeQueryDto {
@IsOptional()
@IsIn(['up', 'down', 'both'])
direction?: 'up' | 'down' | 'both' = 'both';
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(10)
depth?: number = 1;
}
/**
*
*/
export class LedgerQueryDto {
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
}
/**
*
*/
export class WalletLedgerQueryDto extends LedgerQueryDto {
@IsOptional()
@IsString()
assetType?: string;
@IsOptional()
@IsString()
entryType?: string;
}

View File

@ -0,0 +1,266 @@
/**
* DTO
*/
// ============================================================================
// 用户完整信息
// ============================================================================
/**
*
*/
export class ReferralInfoDto {
myReferralCode!: string;
usedReferralCode!: string | null;
referrerId!: string | null;
referrerSequence!: string | null;
referrerNickname!: string | null;
ancestorPath!: string | null;
depth!: number;
directReferralCount!: number;
activeDirectCount!: number;
}
/**
*
*/
export class UserFullDetailDto {
accountId!: string;
accountSequence!: string;
avatar!: string | null;
nickname!: string | null;
phoneNumberMasked!: string | null;
status!: 'active' | 'frozen' | 'deactivated';
kycStatus!: string;
isOnline!: boolean;
registeredAt!: string;
lastActiveAt!: string | null;
// 认种统计
personalAdoptions!: number;
teamAddresses!: number;
teamAdoptions!: number;
provincialAdoptions!: {
count: number;
percentage: number;
};
cityAdoptions!: {
count: number;
percentage: number;
};
// 排名
ranking!: number | null;
// 推荐信息
referralInfo!: ReferralInfoDto;
}
// ============================================================================
// 推荐关系树
// ============================================================================
/**
*
*/
export class ReferralNodeDto {
accountSequence!: string;
userId!: string;
nickname!: string | null;
avatar!: string | null;
personalAdoptions!: number;
depth!: number;
directReferralCount!: number;
isCurrentUser?: boolean;
}
/**
*
*/
export class ReferralTreeDto {
currentUser!: ReferralNodeDto;
ancestors!: ReferralNodeDto[]; // 向上的推荐人链
directReferrals!: ReferralNodeDto[]; // 直推用户列表
}
// ============================================================================
// 认种分类账
// ============================================================================
/**
*
*/
export class PlantingSummaryDto {
totalOrders!: number;
totalTreeCount!: number;
totalAmount!: string;
effectiveTreeCount!: number;
pendingTreeCount!: number;
firstPlantingAt!: string | null;
lastPlantingAt!: string | null;
}
/**
*
*/
export class PlantingLedgerItemDto {
orderId!: string;
orderNo!: string;
treeCount!: number;
totalAmount!: string;
status!: string;
selectedProvince!: string | null;
selectedCity!: string | null;
createdAt!: string;
paidAt!: string | null;
fundAllocatedAt!: string | null;
miningEnabledAt!: string | null;
}
/**
*
*/
export class PlantingLedgerResponseDto {
summary!: PlantingSummaryDto;
items!: PlantingLedgerItemDto[];
total!: number;
page!: number;
pageSize!: number;
totalPages!: number;
}
// ============================================================================
// 钱包分类账
// ============================================================================
/**
*
*/
export class WalletSummaryDto {
// USDT
usdtAvailable!: string;
usdtFrozen!: string;
// DST
dstAvailable!: string;
dstFrozen!: string;
// BNB
bnbAvailable!: string;
bnbFrozen!: string;
// OG
ogAvailable!: string;
ogFrozen!: string;
// RWAD
rwadAvailable!: string;
rwadFrozen!: string;
// 算力
hashpower!: string;
// 收益
pendingUsdt!: string;
pendingHashpower!: string;
settleableUsdt!: string;
settleableHashpower!: string;
settledTotalUsdt!: string;
settledTotalHashpower!: string;
expiredTotalUsdt!: string;
expiredTotalHashpower!: string;
}
/**
*
*/
export class WalletLedgerItemDto {
entryId!: string;
entryType!: string;
assetType!: string;
amount!: string;
balanceAfter!: string | null;
refOrderId!: string | null;
refTxHash!: string | null;
memo!: string | null;
createdAt!: string;
}
/**
*
*/
export class WalletLedgerResponseDto {
summary!: WalletSummaryDto;
items!: WalletLedgerItemDto[];
total!: number;
page!: number;
pageSize!: number;
totalPages!: number;
}
// ============================================================================
// 授权信息
// ============================================================================
/**
*
*/
export class AuthorizationRoleDto {
id!: string;
roleType!: string;
regionCode!: string;
regionName!: string;
displayTitle!: string;
status!: string;
benefitActive!: boolean;
benefitActivatedAt!: string | null;
authorizedAt!: string | null;
authorizedBy!: string | null;
initialTargetTreeCount!: number;
monthlyTargetType!: string;
lastAssessmentMonth!: string | null;
monthlyTreesAdded!: number;
createdAt!: string;
}
/**
*
*/
export class MonthlyAssessmentDto {
id!: string;
authorizationId!: string;
roleType!: string;
regionCode!: string;
assessmentMonth!: string;
monthIndex!: number;
monthlyTarget!: number;
monthlyCompleted!: number;
cumulativeTarget!: number;
cumulativeCompleted!: number;
result!: string;
rankingInRegion!: number | null;
isFirstPlace!: boolean;
isBypassed!: boolean;
completedAt!: string | null;
assessedAt!: string | null;
}
/**
*
*/
export class SystemAccountLedgerItemDto {
ledgerId!: string;
accountId!: string;
accountType!: string;
entryType!: string;
amount!: string;
balanceAfter!: string;
sourceOrderId!: string | null;
sourceRewardId!: string | null;
txHash!: string | null;
memo!: string | null;
createdAt!: string;
}
/**
*
*/
export class AuthorizationDetailResponseDto {
roles!: AuthorizationRoleDto[];
assessments!: MonthlyAssessmentDto[];
systemAccountLedger!: SystemAccountLedgerItemDto[];
}

View File

@ -32,7 +32,11 @@ import { AdminNotificationController, MobileNotificationController } from './api
import { UserQueryRepositoryImpl } from './infrastructure/persistence/repositories/user-query.repository.impl';
import { USER_QUERY_REPOSITORY } from './domain/repositories/user-query.repository';
import { UserController } from './api/controllers/user.controller';
import { UserDetailController } from './api/controllers/user-detail.controller';
import { UserEventConsumerService } from './infrastructure/kafka/user-event-consumer.service';
// User Detail Query imports
import { UserDetailQueryRepositoryImpl } from './infrastructure/persistence/repositories/user-detail-query.repository.impl';
import { USER_DETAIL_QUERY_REPOSITORY } from './domain/repositories/user-detail-query.repository';
// System Config imports
import { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl';
import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository';
@ -89,6 +93,7 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
AdminNotificationController,
MobileNotificationController,
UserController,
UserDetailController,
AdminSystemConfigController,
PublicSystemConfigController,
// User Profile System Controllers
@ -131,6 +136,10 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
provide: USER_QUERY_REPOSITORY,
useClass: UserQueryRepositoryImpl,
},
{
provide: USER_DETAIL_QUERY_REPOSITORY,
useClass: UserDetailQueryRepositoryImpl,
},
UserEventConsumerService,
// System Config
{

View File

@ -0,0 +1,248 @@
/**
*
* admin-web
*/
// ============================================================================
// 推荐关系相关
// ============================================================================
export interface ReferralInfo {
userId: bigint;
accountSequence: string;
myReferralCode: string;
usedReferralCode: string | null;
referrerId: bigint | null;
ancestorPath: bigint[];
depth: number;
directReferralCount: number;
activeDirectCount: number;
}
export interface ReferralNode {
userId: bigint;
accountSequence: string;
nickname: string | null;
avatarUrl: string | null;
personalAdoptionCount: number;
depth: number;
directReferralCount: number;
}
// ============================================================================
// 认种相关
// ============================================================================
export interface PlantingSummary {
totalOrders: number;
totalTreeCount: number;
totalAmount: bigint; // Decimal as bigint for precision
effectiveTreeCount: number;
pendingTreeCount: number;
firstPlantingAt: Date | null;
lastPlantingAt: Date | null;
}
export interface PlantingLedgerItem {
orderId: bigint;
orderNo: string;
treeCount: number;
totalAmount: bigint;
status: string;
selectedProvince: string | null;
selectedCity: string | null;
createdAt: Date;
paidAt: Date | null;
fundAllocatedAt: Date | null;
miningEnabledAt: Date | null;
}
export interface PlantingLedgerResult {
items: PlantingLedgerItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// ============================================================================
// 钱包相关
// ============================================================================
export interface WalletSummary {
usdtAvailable: bigint;
usdtFrozen: bigint;
dstAvailable: bigint;
dstFrozen: bigint;
bnbAvailable: bigint;
bnbFrozen: bigint;
ogAvailable: bigint;
ogFrozen: bigint;
rwadAvailable: bigint;
rwadFrozen: bigint;
hashpower: bigint;
pendingUsdt: bigint;
pendingHashpower: bigint;
settleableUsdt: bigint;
settleableHashpower: bigint;
settledTotalUsdt: bigint;
settledTotalHashpower: bigint;
expiredTotalUsdt: bigint;
expiredTotalHashpower: bigint;
}
export interface WalletLedgerItem {
entryId: bigint;
entryType: string;
assetType: string;
amount: bigint;
balanceAfter: bigint | null;
refOrderId: string | null;
refTxHash: string | null;
memo: string | null;
createdAt: Date;
}
export interface WalletLedgerResult {
items: WalletLedgerItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface WalletLedgerFilters {
assetType?: string;
entryType?: string;
startDate?: Date;
endDate?: Date;
}
// ============================================================================
// 授权相关
// ============================================================================
export interface AuthorizationRole {
id: string;
roleType: string;
regionCode: string;
regionName: string;
displayTitle: string;
status: string;
benefitActive: boolean;
benefitActivatedAt: Date | null;
authorizedAt: Date | null;
authorizedBy: string | null;
initialTargetTreeCount: number;
monthlyTargetType: string;
lastAssessmentMonth: string | null;
monthlyTreesAdded: number;
createdAt: Date;
}
export interface MonthlyAssessment {
id: string;
authorizationId: string;
roleType: string;
regionCode: string;
assessmentMonth: string;
monthIndex: number;
monthlyTarget: number;
monthlyCompleted: number;
cumulativeTarget: number;
cumulativeCompleted: number;
result: string;
rankingInRegion: number | null;
isFirstPlace: boolean;
isBypassed: boolean;
completedAt: Date | null;
assessedAt: Date | null;
}
export interface SystemAccountLedger {
ledgerId: bigint;
accountId: bigint;
accountType: string;
entryType: string;
amount: bigint;
balanceAfter: bigint;
sourceOrderId: bigint | null;
sourceRewardId: bigint | null;
txHash: string | null;
memo: string | null;
createdAt: Date;
}
// ============================================================================
// 仓储接口
// ============================================================================
export const USER_DETAIL_QUERY_REPOSITORY = Symbol('USER_DETAIL_QUERY_REPOSITORY');
export interface IUserDetailQueryRepository {
/**
*
*/
getReferralInfo(accountSequence: string): Promise<ReferralInfo | null>;
/**
*
*/
getAncestors(accountSequence: string, depth: number): Promise<ReferralNode[]>;
/**
*
*/
getDirectReferrals(accountSequence: string): Promise<ReferralNode[]>;
/**
*
*/
getReferrerNickname(referrerId: bigint): Promise<string | null>;
/**
*
*/
getPlantingSummary(userId: bigint): Promise<PlantingSummary | null>;
/**
*
*/
getPlantingLedger(
userId: bigint,
page: number,
pageSize: number,
startDate?: Date,
endDate?: Date,
): Promise<PlantingLedgerResult>;
/**
*
*/
getWalletSummary(userId: bigint): Promise<WalletSummary | null>;
/**
*
*/
getWalletLedger(
userId: bigint,
page: number,
pageSize: number,
filters?: WalletLedgerFilters,
): Promise<WalletLedgerResult>;
/**
*
*/
getAuthorizationRoles(accountSequence: string): Promise<AuthorizationRole[]>;
/**
*
*/
getMonthlyAssessments(accountSequence: string): Promise<MonthlyAssessment[]>;
/**
*
*/
getSystemAccountLedger(accountSequence: string): Promise<SystemAccountLedger[]>;
}

View File

@ -0,0 +1,425 @@
import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../prisma/prisma.service';
import {
IUserDetailQueryRepository,
ReferralInfo,
ReferralNode,
PlantingSummary,
PlantingLedgerItem,
PlantingLedgerResult,
WalletSummary,
WalletLedgerItem,
WalletLedgerResult,
WalletLedgerFilters,
AuthorizationRole,
MonthlyAssessment,
SystemAccountLedger,
} from '../../../domain/repositories/user-detail-query.repository';
@Injectable()
export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository {
constructor(private readonly prisma: PrismaService) {}
// ============================================================================
// 推荐关系相关
// ============================================================================
async getReferralInfo(accountSequence: string): Promise<ReferralInfo | null> {
const referral = await this.prisma.referralQueryView.findUnique({
where: { accountSequence },
});
if (!referral) return null;
return {
userId: referral.userId,
accountSequence: referral.accountSequence,
myReferralCode: referral.myReferralCode,
usedReferralCode: referral.usedReferralCode,
referrerId: referral.referrerId,
ancestorPath: referral.ancestorPath,
depth: referral.depth,
directReferralCount: referral.directReferralCount,
activeDirectCount: referral.activeDirectCount,
};
}
async getAncestors(accountSequence: string, depth: number): Promise<ReferralNode[]> {
const referral = await this.prisma.referralQueryView.findUnique({
where: { accountSequence },
});
if (!referral || referral.ancestorPath.length === 0) {
return [];
}
// 获取祖先列表(从直接推荐人开始,最多 depth 层)
const ancestorIds = referral.ancestorPath.slice(0, depth);
if (ancestorIds.length === 0) return [];
// 获取祖先用户信息
const [users, referrals] = await Promise.all([
this.prisma.userQueryView.findMany({
where: { userId: { in: ancestorIds } },
select: {
userId: true,
accountSequence: true,
nickname: true,
avatarUrl: true,
personalAdoptionCount: true,
},
}),
this.prisma.referralQueryView.findMany({
where: { userId: { in: ancestorIds } },
select: {
userId: true,
depth: true,
directReferralCount: true,
},
}),
]);
// 合并数据
const referralMap = new Map(referrals.map((r) => [r.userId.toString(), r]));
const userMap = new Map(users.map((u) => [u.userId.toString(), u]));
return ancestorIds.map((id, index) => {
const user = userMap.get(id.toString());
const ref = referralMap.get(id.toString());
return {
userId: id,
accountSequence: user?.accountSequence || '',
nickname: user?.nickname || null,
avatarUrl: user?.avatarUrl || null,
personalAdoptionCount: user?.personalAdoptionCount || 0,
depth: ref?.depth || index,
directReferralCount: ref?.directReferralCount || 0,
};
});
}
async getDirectReferrals(accountSequence: string): Promise<ReferralNode[]> {
const referral = await this.prisma.referralQueryView.findUnique({
where: { accountSequence },
});
if (!referral) return [];
// 查找直接推荐的用户referrerId = 当前用户的 userId
const directReferrals = await this.prisma.referralQueryView.findMany({
where: { referrerId: referral.userId },
select: {
userId: true,
accountSequence: true,
depth: true,
directReferralCount: true,
},
});
if (directReferrals.length === 0) return [];
// 获取用户信息
const users = await this.prisma.userQueryView.findMany({
where: { userId: { in: directReferrals.map((r) => r.userId) } },
select: {
userId: true,
accountSequence: true,
nickname: true,
avatarUrl: true,
personalAdoptionCount: true,
},
});
const userMap = new Map(users.map((u) => [u.userId.toString(), u]));
return directReferrals.map((ref) => {
const user = userMap.get(ref.userId.toString());
return {
userId: ref.userId,
accountSequence: ref.accountSequence,
nickname: user?.nickname || null,
avatarUrl: user?.avatarUrl || null,
personalAdoptionCount: user?.personalAdoptionCount || 0,
depth: ref.depth,
directReferralCount: ref.directReferralCount,
};
});
}
async getReferrerNickname(referrerId: bigint): Promise<string | null> {
const user = await this.prisma.userQueryView.findUnique({
where: { userId: referrerId },
select: { nickname: true },
});
return user?.nickname || null;
}
// ============================================================================
// 认种相关
// ============================================================================
async getPlantingSummary(userId: bigint): Promise<PlantingSummary | null> {
// 获取持仓信息
const position = await this.prisma.plantingPositionQueryView.findUnique({
where: { userId },
});
// 获取订单统计
const [orderStats, firstOrder, lastOrder] = await Promise.all([
this.prisma.plantingOrderQueryView.aggregate({
where: { userId },
_count: true,
_sum: {
treeCount: true,
totalAmount: true,
},
}),
this.prisma.plantingOrderQueryView.findFirst({
where: { userId, paidAt: { not: null } },
orderBy: { paidAt: 'asc' },
select: { paidAt: true },
}),
this.prisma.plantingOrderQueryView.findFirst({
where: { userId, paidAt: { not: null } },
orderBy: { paidAt: 'desc' },
select: { paidAt: true },
}),
]);
return {
totalOrders: orderStats._count,
totalTreeCount: orderStats._sum.treeCount || 0,
totalAmount: this.decimalToBigint(orderStats._sum.totalAmount),
effectiveTreeCount: position?.effectiveTreeCount || 0,
pendingTreeCount: position?.pendingTreeCount || 0,
firstPlantingAt: firstOrder?.paidAt || null,
lastPlantingAt: lastOrder?.paidAt || null,
};
}
async getPlantingLedger(
userId: bigint,
page: number,
pageSize: number,
startDate?: Date,
endDate?: Date,
): Promise<PlantingLedgerResult> {
const where: any = { userId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [total, items] = await Promise.all([
this.prisma.plantingOrderQueryView.count({ where }),
this.prisma.plantingOrderQueryView.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
]);
return {
items: items.map((item) => ({
orderId: item.id,
orderNo: item.orderNo,
treeCount: item.treeCount,
totalAmount: this.decimalToBigint(item.totalAmount),
status: item.status,
selectedProvince: item.selectedProvince,
selectedCity: item.selectedCity,
createdAt: item.createdAt,
paidAt: item.paidAt,
fundAllocatedAt: item.fundAllocatedAt,
miningEnabledAt: item.miningEnabledAt,
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
// ============================================================================
// 钱包相关
// ============================================================================
async getWalletSummary(userId: bigint): Promise<WalletSummary | null> {
const wallet = await this.prisma.walletAccountQueryView.findUnique({
where: { userId },
});
if (!wallet) return null;
return {
usdtAvailable: this.decimalToBigint(wallet.usdtAvailable),
usdtFrozen: this.decimalToBigint(wallet.usdtFrozen),
dstAvailable: this.decimalToBigint(wallet.dstAvailable),
dstFrozen: this.decimalToBigint(wallet.dstFrozen),
bnbAvailable: this.decimalToBigint(wallet.bnbAvailable),
bnbFrozen: this.decimalToBigint(wallet.bnbFrozen),
ogAvailable: this.decimalToBigint(wallet.ogAvailable),
ogFrozen: this.decimalToBigint(wallet.ogFrozen),
rwadAvailable: this.decimalToBigint(wallet.rwadAvailable),
rwadFrozen: this.decimalToBigint(wallet.rwadFrozen),
hashpower: this.decimalToBigint(wallet.hashpower),
pendingUsdt: this.decimalToBigint(wallet.pendingUsdt),
pendingHashpower: this.decimalToBigint(wallet.pendingHashpower),
settleableUsdt: this.decimalToBigint(wallet.settleableUsdt),
settleableHashpower: this.decimalToBigint(wallet.settleableHashpower),
settledTotalUsdt: this.decimalToBigint(wallet.settledTotalUsdt),
settledTotalHashpower: this.decimalToBigint(wallet.settledTotalHashpower),
expiredTotalUsdt: this.decimalToBigint(wallet.expiredTotalUsdt),
expiredTotalHashpower: this.decimalToBigint(wallet.expiredTotalHashpower),
};
}
async getWalletLedger(
userId: bigint,
page: number,
pageSize: number,
filters?: WalletLedgerFilters,
): Promise<WalletLedgerResult> {
const where: any = { userId };
if (filters?.assetType) {
where.assetType = filters.assetType;
}
if (filters?.entryType) {
where.entryType = filters.entryType;
}
if (filters?.startDate || filters?.endDate) {
where.createdAt = {};
if (filters.startDate) where.createdAt.gte = filters.startDate;
if (filters.endDate) where.createdAt.lte = filters.endDate;
}
const [total, items] = await Promise.all([
this.prisma.walletLedgerEntryView.count({ where }),
this.prisma.walletLedgerEntryView.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
]);
return {
items: items.map((item) => ({
entryId: item.id,
entryType: item.entryType,
assetType: item.assetType,
amount: this.decimalToBigint(item.amount),
balanceAfter: item.balanceAfter ? this.decimalToBigint(item.balanceAfter) : null,
refOrderId: item.refOrderId,
refTxHash: item.refTxHash,
memo: item.memo,
createdAt: item.createdAt,
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
// ============================================================================
// 授权相关
// ============================================================================
async getAuthorizationRoles(accountSequence: string): Promise<AuthorizationRole[]> {
const roles = await this.prisma.authorizationRoleQueryView.findMany({
where: {
accountSequence,
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
return roles.map((role) => ({
id: role.id,
roleType: role.roleType,
regionCode: role.regionCode,
regionName: role.regionName,
displayTitle: role.displayTitle,
status: role.status,
benefitActive: role.benefitActive,
benefitActivatedAt: role.benefitActivatedAt,
authorizedAt: role.authorizedAt,
authorizedBy: role.authorizedBy,
initialTargetTreeCount: role.initialTargetTreeCount,
monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded,
createdAt: role.createdAt,
}));
}
async getMonthlyAssessments(accountSequence: string): Promise<MonthlyAssessment[]> {
const assessments = await this.prisma.monthlyAssessmentQueryView.findMany({
where: { accountSequence },
orderBy: [{ assessmentMonth: 'desc' }, { createdAt: 'desc' }],
});
return assessments.map((assessment) => ({
id: assessment.id,
authorizationId: assessment.authorizationId,
roleType: assessment.roleType,
regionCode: assessment.regionCode,
assessmentMonth: assessment.assessmentMonth,
monthIndex: assessment.monthIndex,
monthlyTarget: assessment.monthlyTarget,
monthlyCompleted: assessment.monthlyCompleted,
cumulativeTarget: assessment.cumulativeTarget,
cumulativeCompleted: assessment.cumulativeCompleted,
result: assessment.result,
rankingInRegion: assessment.rankingInRegion,
isFirstPlace: assessment.isFirstPlace,
isBypassed: assessment.isBypassed,
completedAt: assessment.completedAt,
assessedAt: assessment.assessedAt,
}));
}
async getSystemAccountLedger(accountSequence: string): Promise<SystemAccountLedger[]> {
// 先获取用户的授权角色
const roles = await this.prisma.authorizationRoleQueryView.findMany({
where: {
accountSequence,
deletedAt: null,
},
select: { id: true },
});
if (roles.length === 0) return [];
// 注SystemAccountLedgerView 不直接关联用户,这里暂时返回空
// 实际业务中可能需要根据授权角色ID或区域来查询相关流水
// 这里简化处理,如果需要可以通过其他方式关联
return [];
}
// ============================================================================
// 辅助方法
// ============================================================================
private decimalToBigint(decimal: Decimal | null | undefined): bigint {
if (!decimal) return BigInt(0);
// 转换为字符串后解析,保留精度
const str = decimal.toString();
// 移除小数点,按整数处理
const parts = str.split('.');
if (parts.length === 1) {
return BigInt(parts[0]);
}
// 有小数部分,乘以 10^小数位数
const scale = parts[1].length;
const intPart = parts[0] + parts[1];
// 返回原始数值(不做缩放,保持 decimal 格式)
return BigInt(Math.round(parseFloat(str) * 1e8));
}
}

View File

@ -0,0 +1,342 @@
# 用户详情页面实现计划
## 1. 页面概述
创建独立的用户详情页面 `/users/[id]`,展示用户的完整信息。
## 2. 页面结构
```
/users/[id]/page.tsx
├── 用户基本信息卡片
│ ├── 头像、昵称、账户序号
│ ├── 手机号(脱敏)
│ ├── KYC 状态
│ ├── 注册时间、最后活跃时间
│ └── 用户状态(正常/冻结)
├── 推荐关系树(倒树形结构)
│ ├── 当前用户为根节点
│ ├── 向上显示推荐人链路ancestor_path
│ ├── 向下显示直推用户列表
│ ├── 点击任意节点可切换查看该节点的关系树
│ └── 显示每个节点的基本信息(序号、昵称、认种量)
├── 认种信息 Tab
│ ├── 认种汇总
│ │ ├── 个人认种总量
│ │ ├── 团队认种总量
│ │ ├── 首次认种时间
│ │ └── 最近认种时间
│ └── 认种分类账明细(表格)
│ ├── 订单号
│ ├── 认种数量
│ ├── 金额
│ ├── 状态
│ ├── 省市选择
│ └── 时间
├── 钱包信息 Tab
│ ├── 钱包汇总
│ │ ├── USDT 余额(可用/冻结)
│ │ ├── DST 余额
│ │ ├── 算力余额
│ │ ├── 待领取收益
│ │ ├── 可结算收益
│ │ └── 已结算收益
│ └── 钱包分类账明细(表格)
│ ├── 流水ID
│ ├── 类型(充值/提现/收益/扣款等)
│ ├── 资产类型
│ ├── 金额
│ ├── 余额快照
│ ├── 关联订单
│ └── 时间
└── 授权信息 Tab
├── 授权汇总
│ ├── 授权角色列表(社区合伙人/省公司/市公司等)
│ ├── 授权状态
│ ├── 授权区域
│ └── 权益激活状态
└── 授权分类账明细(表格)
├── 月度考核记录
│ ├── 考核月份
│ ├── 月度目标/完成
│ ├── 累计目标/完成
│ ├── 考核结果
│ └── 区域排名
└── 系统账户流水(如果是省/市公司)
├── 流水ID
├── 账户类型
├── 流水类型
├── 金额
├── 余额
└── 时间
```
## 3. 需要创建的文件
### 3.1 前端文件
```
frontend/admin-web/src/
├── app/(dashboard)/users/[id]/
│ ├── page.tsx # 用户详情页面
│ └── user-detail.module.scss # 页面样式
├── components/features/users/
│ ├── ReferralTree/
│ │ ├── ReferralTree.tsx # 推荐关系树组件
│ │ ├── ReferralTree.module.scss
│ │ └── index.ts
│ ├── PlantingTab/
│ │ ├── PlantingTab.tsx # 认种信息 Tab
│ │ ├── PlantingTab.module.scss
│ │ └── index.ts
│ ├── WalletTab/
│ │ ├── WalletTab.tsx # 钱包信息 Tab
│ │ ├── WalletTab.module.scss
│ │ └── index.ts
│ └── AuthorizationTab/
│ ├── AuthorizationTab.tsx # 授权信息 Tab
│ ├── AuthorizationTab.module.scss
│ └── index.ts
├── services/
│ └── userDetailService.ts # 用户详情相关 API
├── hooks/
│ └── useUserDetail.ts # 用户详情相关 hooks扩展
└── types/
└── userDetail.types.ts # 用户详情相关类型
```
### 3.2 后端文件admin-service
```
backend/services/admin-service/src/
├── application/
│ ├── queries/
│ │ ├── get-user-full-detail.query.ts
│ │ ├── get-user-referral-tree.query.ts
│ │ ├── get-user-planting-ledger.query.ts
│ │ ├── get-user-wallet-ledger.query.ts
│ │ └── get-user-authorization-detail.query.ts
│ └── handlers/
│ └── (对应的 handler 文件)
├── interfaces/http/controllers/
│ └── user-detail.controller.ts # 用户详情 API 控制器
└── infrastructure/persistence/repositories/
└── user-detail-query.repository.impl.ts
```
## 4. API 端点设计
### 4.1 获取用户完整信息
```
GET /v1/admin/users/:accountSequence/full-detail
Response: {
basicInfo: { ... },
referralInfo: {
myReferralCode: string,
usedReferralCode: string,
referrerId: string,
referrerSequence: string,
ancestorPath: string,
depth: number,
directReferralCount: number,
activeDirectCount: number
}
}
```
### 4.2 获取推荐关系树
```
GET /v1/admin/users/:accountSequence/referral-tree
Query: { direction: 'up' | 'down', depth?: number }
Response: {
currentUser: { accountSequence, nickname, personalAdoptions },
ancestors: [...], // 向上的推荐人链
directReferrals: [...] // 直推用户列表
}
```
### 4.3 获取认种分类账
```
GET /v1/admin/users/:accountSequence/planting-ledger
Query: { page, pageSize, startDate?, endDate? }
Response: {
summary: {
totalOrders: number,
totalTreeCount: number,
totalAmount: string,
firstPlantingAt: string,
lastPlantingAt: string
},
items: [...],
total: number,
page: number,
pageSize: number
}
```
### 4.4 获取钱包分类账
```
GET /v1/admin/users/:accountSequence/wallet-ledger
Query: { page, pageSize, assetType?, entryType?, startDate?, endDate? }
Response: {
summary: {
usdtAvailable: string,
usdtFrozen: string,
dstAvailable: string,
hashpower: string,
pendingUsdt: string,
settleableUsdt: string,
settledTotalUsdt: string
},
items: [...],
total: number,
page: number,
pageSize: number
}
```
### 4.5 获取授权信息
```
GET /v1/admin/users/:accountSequence/authorization-detail
Response: {
roles: [{
id: string,
roleType: string,
regionCode: string,
regionName: string,
displayTitle: string,
status: string,
benefitActive: boolean,
authorizedAt: string
}],
assessments: [{
assessmentMonth: string,
monthlyTarget: number,
monthlyCompleted: number,
cumulativeTarget: number,
cumulativeCompleted: number,
result: string,
rankingInRegion: number
}],
systemAccountLedger: [...] // 如果是省/市公司
}
```
## 5. 数据来源CDC 同步表)
| 数据 | 来源表 | admin-service 视图表 |
|------|--------|---------------------|
| 用户基本信息 | identity.user_accounts | user_query_view |
| 推荐关系 | referral.referral_relationships | referral_query_view |
| 认种订单 | planting.planting_orders | planting_order_query_view |
| 认种资金分配 | planting.fund_allocations | fund_allocation_view |
| 钱包余额 | wallet.wallet_accounts | wallet_account_query_view |
| 钱包流水 | wallet.wallet_ledger_entries | wallet_ledger_entry_view |
| 授权角色 | authorization.authorization_roles | authorization_role_query_view |
| 月度考核 | authorization.monthly_assessments | monthly_assessment_query_view |
| 系统账户流水 | authorization.system_account_ledgers | system_account_ledger_view |
## 6. 推荐关系树组件设计
### 6.1 数据结构
```typescript
interface ReferralNode {
accountSequence: string;
nickname: string | null;
avatar: string | null;
personalAdoptions: number;
depth: number;
isCurrentUser: boolean;
children?: ReferralNode[];
}
```
### 6.2 交互设计
- 初始显示当前用户及其直接推荐人向上1级和直推用户向下1级
- 点击任意节点,该节点变为中心,重新加载其上下级关系
- 使用树形布局,父节点在上,子节点在下
- 节点显示:头像 + 序号 + 昵称 + 认种量
- 当前查看的用户节点高亮显示
### 6.3 实现方案
- 使用 CSS Flexbox/Grid 实现树形布局
- 或使用 react-d3-tree 库
- 或使用 @ant-design/charts 的组织架构图
## 7. Tab 切换设计
```typescript
type TabType = 'planting' | 'wallet' | 'authorization';
const tabs = [
{ key: 'planting', label: '认种信息' },
{ key: 'wallet', label: '钱包信息' },
{ key: 'authorization', label: '授权信息' },
];
```
## 8. 实现顺序
1. **Phase 1: 基础结构**
- 创建页面路由和基本布局
- 实现用户基本信息卡片
- 更新用户列表页面的"查看详情"链接
2. **Phase 2: 后端 API**
- 实现 user-detail.controller.ts
- 添加查询推荐关系的 repository 方法
- 添加查询分类账的 repository 方法
3. **Phase 3: 推荐关系树**
- 实现 ReferralTree 组件
- 实现节点点击切换功能
4. **Phase 4: 认种信息**
- 实现 PlantingTab 组件
- 显示汇总和明细表格
5. **Phase 5: 钱包信息**
- 实现 WalletTab 组件
- 显示汇总和明细表格
6. **Phase 6: 授权信息**
- 实现 AuthorizationTab 组件
- 显示角色列表、考核记录
## 9. 样式规范
- 使用现有的设计系统颜色变量
- 卡片间距: 24px
- 表格使用现有的 Table 组件样式
- Tab 使用自定义样式,与现有风格保持一致
- 树形结构使用清晰的连接线
## 10. 注意事项
1. **性能优化**
- 分类账明细使用分页加载
- 推荐关系树按需加载(点击时才加载子节点)
- 使用 React Query 缓存数据
2. **错误处理**
- 用户不存在时显示友好提示
- 网络错误时显示重试按钮
- 数据为空时显示空状态提示
3. **权限控制**
- 确保只有登录的管理员可以访问
- 敏感信息(如完整手机号)不显示
4. **响应式设计**
- 表格在小屏幕上可横向滚动
- 推荐关系树在小屏幕上可缩放

View File

@ -0,0 +1,803 @@
'use client';
import { useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { Button, toast } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber, formatRanking } from '@/utils/formatters';
import {
useUserFullDetail,
useReferralTree,
usePlantingLedger,
useWalletLedger,
useAuthorizationDetail,
} from '@/hooks/useUserDetailPage';
import type {
ReferralNode,
PlantingLedgerItem,
WalletLedgerItem,
WALLET_ENTRY_TYPE_LABELS,
ASSET_TYPE_LABELS,
PLANTING_STATUS_LABELS,
AUTHORIZATION_ROLE_LABELS,
AUTHORIZATION_STATUS_LABELS,
ASSESSMENT_RESULT_LABELS,
} from '@/types/userDetail.types';
import styles from './user-detail.module.scss';
// Tab 类型
type TabType = 'referral' | 'planting' | 'wallet' | 'authorization';
const tabs: { key: TabType; label: string }[] = [
{ key: 'referral', label: '推荐关系' },
{ key: 'planting', label: '认种信息' },
{ key: 'wallet', label: '钱包信息' },
{ key: 'authorization', label: '授权信息' },
];
// 流水类型标签
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: '手续费',
};
const assetTypeLabels: Record<string, string> = {
USDT: 'USDT',
DST: 'DST',
BNB: 'BNB',
OG: 'OG',
RWAD: 'RWAD',
HASHPOWER: '算力',
};
const plantingStatusLabels: Record<string, string> = {
CREATED: '已创建',
PAID: '已支付',
FUND_ALLOCATED: '资金已分配',
MINING_ENABLED: '已开始挖矿',
CANCELLED: '已取消',
EXPIRED: '已过期',
};
const roleTypeLabels: Record<string, string> = {
COMMUNITY_PARTNER: '社区合伙人',
PROVINCE_COMPANY: '省公司',
CITY_COMPANY: '市公司',
AUTH_PROVINCE_COMPANY: '授权省公司',
AUTH_CITY_COMPANY: '授权市公司',
};
const authStatusLabels: Record<string, string> = {
PENDING: '待授权',
AUTHORIZED: '已授权',
REVOKED: '已撤销',
EXPIRED: '已过期',
};
const assessmentResultLabels: Record<string, string> = {
NOT_ASSESSED: '未考核',
PASSED: '通过',
FAILED: '未通过',
BYPASSED: '豁免',
};
/**
*
*/
export default function UserDetailPage() {
const params = useParams();
const router = useRouter();
const accountSequence = params.id as string;
const [activeTab, setActiveTab] = useState<TabType>('referral');
const [treeRootUser, setTreeRootUser] = useState<string>(accountSequence);
const [plantingPage, setPlantingPage] = useState(1);
const [walletPage, setWalletPage] = useState(1);
// 获取用户完整信息
const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence);
// 获取推荐关系树(以当前选中的用户为根)
const { data: referralTree, isLoading: treeLoading } = useReferralTree(treeRootUser, 'both', 1);
// 获取认种分类账
const { data: plantingData, isLoading: plantingLoading } = usePlantingLedger(accountSequence, {
page: plantingPage,
pageSize: 10,
});
// 获取钱包分类账
const { data: walletData, isLoading: walletLoading } = useWalletLedger(accountSequence, {
page: walletPage,
pageSize: 10,
});
// 获取授权信息
const { data: authData, isLoading: authLoading } = useAuthorizationDetail(accountSequence);
// 切换推荐关系树的根节点
const handleTreeNodeClick = useCallback((node: ReferralNode) => {
setTreeRootUser(node.accountSequence);
}, []);
// 返回列表
const handleBack = useCallback(() => {
router.push('/users');
}, [router]);
// 格式化日期
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('zh-CN');
};
// 格式化金额
const formatAmount = (amount: string | null) => {
if (!amount) return '-';
const num = parseFloat(amount);
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 8 });
};
if (detailLoading) {
return (
<PageContainer title="用户详情">
<div className={styles.loading}>...</div>
</PageContainer>
);
}
if (detailError || !userDetail) {
return (
<PageContainer title="用户详情">
<div className={styles.error}>
<p>: {(detailError as Error)?.message || '用户不存在'}</p>
<Button onClick={handleBack}></Button>
</div>
</PageContainer>
);
}
return (
<PageContainer title={`用户详情 - ${userDetail.accountSequence}`}>
<div className={styles.userDetail}>
{/* 返回按钮 */}
<div className={styles.userDetail__backBar}>
<button className={styles.userDetail__backBtn} onClick={handleBack}>
<span className={styles.userDetail__backIcon}></span>
</button>
</div>
{/* 用户基本信息卡片 */}
<div className={styles.userDetail__basicCard}>
<div className={styles.userDetail__basicHeader}>
<div
className={styles.userDetail__avatar}
style={{ backgroundImage: `url(${userDetail.avatar || '/images/Data@2x.png'})` }}
>
<div
className={cn(
styles.userDetail__status,
userDetail.isOnline ? styles['userDetail__status--online'] : styles['userDetail__status--offline']
)}
/>
</div>
<div className={styles.userDetail__basicInfo}>
<h1 className={styles.userDetail__nickname}>
{userDetail.nickname || '未设置昵称'}
<span className={cn(
styles.userDetail__statusBadge,
styles[`userDetail__statusBadge--${userDetail.status}`]
)}>
{userDetail.status === 'active' ? '正常' : userDetail.status === 'frozen' ? '冻结' : '停用'}
</span>
</h1>
<div className={styles.userDetail__basicMeta}>
<span>: <strong>{userDetail.accountSequence}</strong></span>
<span>: {userDetail.phoneNumberMasked || '未绑定'}</span>
<span>KYC: {userDetail.kycStatus}</span>
</div>
<div className={styles.userDetail__basicMeta}>
<span>: {formatDate(userDetail.registeredAt)}</span>
<span>: {formatDate(userDetail.lastActiveAt)}</span>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className={styles.userDetail__statsGrid}>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.personalAdoptions)}</span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAdoptions)}</span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAddresses)}</span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={cn(
styles.userDetail__statValue,
userDetail.ranking && userDetail.ranking <= 10 && styles['userDetail__statValue--gold']
)}>
{userDetail.ranking ? formatRanking(userDetail.ranking) : '-'}
</span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(userDetail.referralInfo.directReferralCount)}
</span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(userDetail.referralInfo.activeDirectCount)}
</span>
</div>
</div>
{/* 推荐人信息 */}
{userDetail.referralInfo.referrerSequence && (
<div className={styles.userDetail__referrerInfo}>
<span className={styles.userDetail__referrerLabel}>:</span>
<Link
href={`/users/${userDetail.referralInfo.referrerSequence}`}
className={styles.userDetail__referrerLink}
>
{userDetail.referralInfo.referrerSequence}
{userDetail.referralInfo.referrerNickname && ` (${userDetail.referralInfo.referrerNickname})`}
</Link>
<span className={styles.userDetail__referrerMeta}>
: {userDetail.referralInfo.usedReferralCode || '-'}
</span>
<span className={styles.userDetail__referrerMeta}>
: {userDetail.referralInfo.depth}
</span>
</div>
)}
</div>
{/* Tab 切换 */}
<div className={styles.userDetail__tabs}>
{tabs.map((tab) => (
<button
key={tab.key}
className={cn(
styles.userDetail__tab,
activeTab === tab.key && styles['userDetail__tab--active']
)}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div className={styles.userDetail__tabContent}>
{/* 推荐关系 Tab */}
{activeTab === 'referral' && (
<div className={styles.referralTab}>
<div className={styles.referralTab__header}>
<h3></h3>
{treeRootUser !== accountSequence && (
<Button
variant="outline"
size="sm"
onClick={() => setTreeRootUser(accountSequence)}
>
</Button>
)}
</div>
{treeLoading ? (
<div className={styles.referralTab__loading}>...</div>
) : referralTree ? (
<div className={styles.referralTree}>
{/* 向上的推荐人链 */}
{referralTree.ancestors.length > 0 && (
<div className={styles.referralTree__ancestors}>
<div className={styles.referralTree__label}> ()</div>
<div className={styles.referralTree__nodeList}>
{referralTree.ancestors.map((ancestor, index) => (
<div key={ancestor.accountSequence} className={styles.referralTree__nodeWrapper}>
<button
className={styles.referralTree__node}
onClick={() => handleTreeNodeClick(ancestor)}
>
<span className={styles.referralTree__nodeSeq}>{ancestor.accountSequence}</span>
<span className={styles.referralTree__nodeNickname}>
{ancestor.nickname || '未设置'}
</span>
<span className={styles.referralTree__nodeAdoptions}>
: {formatNumber(ancestor.personalAdoptions)}
</span>
</button>
{index < referralTree.ancestors.length - 1 && (
<div className={styles.referralTree__connector}></div>
)}
</div>
))}
</div>
<div className={styles.referralTree__connector}></div>
</div>
)}
{/* 当前用户 */}
<div className={styles.referralTree__current}>
<div
className={cn(
styles.referralTree__node,
styles['referralTree__node--current'],
referralTree.currentUser.accountSequence === accountSequence &&
styles['referralTree__node--highlight']
)}
>
<span className={styles.referralTree__nodeSeq}>
{referralTree.currentUser.accountSequence}
</span>
<span className={styles.referralTree__nodeNickname}>
{referralTree.currentUser.nickname || '未设置'}
</span>
<span className={styles.referralTree__nodeAdoptions}>
: {formatNumber(referralTree.currentUser.personalAdoptions)}
</span>
<span className={styles.referralTree__nodeCount}>
: {formatNumber(referralTree.currentUser.directReferralCount)}
</span>
</div>
</div>
{/* 直推用户 */}
{referralTree.directReferrals.length > 0 && (
<div className={styles.referralTree__directReferrals}>
<div className={styles.referralTree__connector}></div>
<div className={styles.referralTree__label}>
({referralTree.directReferrals.length})
</div>
<div className={styles.referralTree__nodeGrid}>
{referralTree.directReferrals.map((referral) => (
<button
key={referral.accountSequence}
className={styles.referralTree__node}
onClick={() => handleTreeNodeClick(referral)}
>
<span className={styles.referralTree__nodeSeq}>{referral.accountSequence}</span>
<span className={styles.referralTree__nodeNickname}>
{referral.nickname || '未设置'}
</span>
<span className={styles.referralTree__nodeAdoptions}>
: {formatNumber(referral.personalAdoptions)}
</span>
{referral.directReferralCount > 0 && (
<span className={styles.referralTree__nodeCount}>
: {formatNumber(referral.directReferralCount)}
</span>
)}
</button>
))}
</div>
</div>
)}
{referralTree.directReferrals.length === 0 && referralTree.ancestors.length === 0 && (
<div className={styles.referralTree__empty}></div>
)}
</div>
) : (
<div className={styles.referralTab__empty}></div>
)}
</div>
)}
{/* 认种信息 Tab */}
{activeTab === 'planting' && (
<div className={styles.plantingTab}>
{plantingLoading ? (
<div className={styles.plantingTab__loading}>...</div>
) : plantingData ? (
<>
{/* 认种汇总 */}
<div className={styles.plantingTab__summary}>
<h3></h3>
<div className={styles.plantingTab__summaryGrid}>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(plantingData.summary.totalOrders)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(plantingData.summary.totalTreeCount)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}> (USDT)</span>
<span className={styles.plantingTab__summaryValue}>
{formatAmount(plantingData.summary.totalAmount)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(plantingData.summary.effectiveTreeCount)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatDate(plantingData.summary.firstPlantingAt)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatDate(plantingData.summary.lastPlantingAt)}
</span>
</div>
</div>
</div>
{/* 认种分类账 */}
<div className={styles.plantingTab__ledger}>
<h3></h3>
<div className={styles.ledgerTable}>
<div className={styles.ledgerTable__header}>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
</div>
{plantingData.items.length === 0 ? (
<div className={styles.ledgerTable__empty}></div>
) : (
plantingData.items.map((item) => (
<div key={item.orderId} className={styles.ledgerTable__row}>
<div className={styles.ledgerTable__cell}>{item.orderNo}</div>
<div className={styles.ledgerTable__cell}>{formatNumber(item.treeCount)}</div>
<div className={styles.ledgerTable__cell}>{formatAmount(item.totalAmount)}</div>
<div className={styles.ledgerTable__cell}>
<span className={cn(
styles.ledgerTable__status,
styles[`ledgerTable__status--${item.status.toLowerCase()}`]
)}>
{plantingStatusLabels[item.status] || item.status}
</span>
</div>
<div className={styles.ledgerTable__cell}>
{item.selectedProvince || '-'} / {item.selectedCity || '-'}
</div>
<div className={styles.ledgerTable__cell}>{formatDate(item.createdAt)}</div>
<div className={styles.ledgerTable__cell}>{formatDate(item.paidAt)}</div>
</div>
))
)}
</div>
{/* 分页 */}
{plantingData.totalPages > 1 && (
<div className={styles.pagination}>
<button
disabled={plantingPage === 1}
onClick={() => setPlantingPage((p) => p - 1)}
>
</button>
<span> {plantingPage} / {plantingData.totalPages} </span>
<button
disabled={plantingPage === plantingData.totalPages}
onClick={() => setPlantingPage((p) => p + 1)}
>
</button>
</div>
)}
</div>
</>
) : (
<div className={styles.plantingTab__empty}></div>
)}
</div>
)}
{/* 钱包信息 Tab */}
{activeTab === 'wallet' && (
<div className={styles.walletTab}>
{walletLoading ? (
<div className={styles.walletTab__loading}>...</div>
) : walletData ? (
<>
{/* 钱包汇总 */}
<div className={styles.walletTab__summary}>
<h3></h3>
<div className={styles.walletTab__summaryGrid}>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}>USDT </span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.usdtAvailable)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}>USDT </span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.usdtFrozen)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}>DST </span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.dstAvailable)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}></span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.hashpower)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}></span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.pendingUsdt)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}></span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.settleableUsdt)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}></span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.settledTotalUsdt)}
</span>
</div>
<div className={styles.walletTab__summaryItem}>
<span className={styles.walletTab__summaryLabel}></span>
<span className={styles.walletTab__summaryValue}>
{formatAmount(walletData.summary.expiredTotalUsdt)}
</span>
</div>
</div>
</div>
{/* 钱包分类账 */}
<div className={styles.walletTab__ledger}>
<h3></h3>
<div className={styles.ledgerTable}>
<div className={styles.ledgerTable__header}>
<div className={styles.ledgerTable__cell}>ID</div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
</div>
{walletData.items.length === 0 ? (
<div className={styles.ledgerTable__empty}></div>
) : (
walletData.items.map((item) => (
<div key={item.entryId} className={styles.ledgerTable__row}>
<div className={styles.ledgerTable__cell}>{item.entryId}</div>
<div className={styles.ledgerTable__cell}>
{entryTypeLabels[item.entryType] || item.entryType}
</div>
<div className={styles.ledgerTable__cell}>
{assetTypeLabels[item.assetType] || item.assetType}
</div>
<div className={cn(
styles.ledgerTable__cell,
parseFloat(item.amount) >= 0
? styles['ledgerTable__cell--positive']
: styles['ledgerTable__cell--negative']
)}>
{parseFloat(item.amount) >= 0 ? '+' : ''}{formatAmount(item.amount)}
</div>
<div className={styles.ledgerTable__cell}>
{formatAmount(item.balanceAfter)}
</div>
<div className={styles.ledgerTable__cell}>
{item.refOrderId || item.refTxHash || '-'}
</div>
<div className={styles.ledgerTable__cell}>{formatDate(item.createdAt)}</div>
</div>
))
)}
</div>
{/* 分页 */}
{walletData.totalPages > 1 && (
<div className={styles.pagination}>
<button
disabled={walletPage === 1}
onClick={() => setWalletPage((p) => p - 1)}
>
</button>
<span> {walletPage} / {walletData.totalPages} </span>
<button
disabled={walletPage === walletData.totalPages}
onClick={() => setWalletPage((p) => p + 1)}
>
</button>
</div>
)}
</div>
</>
) : (
<div className={styles.walletTab__empty}></div>
)}
</div>
)}
{/* 授权信息 Tab */}
{activeTab === 'authorization' && (
<div className={styles.authTab}>
{authLoading ? (
<div className={styles.authTab__loading}>...</div>
) : authData ? (
<>
{/* 授权角色列表 */}
<div className={styles.authTab__roles}>
<h3></h3>
{authData.roles.length === 0 ? (
<div className={styles.authTab__empty}></div>
) : (
<div className={styles.authTab__roleGrid}>
{authData.roles.map((role) => (
<div key={role.id} className={styles.authTab__roleCard}>
<div className={styles.authTab__roleHeader}>
<span className={styles.authTab__roleType}>
{roleTypeLabels[role.roleType] || role.roleType}
</span>
<span className={cn(
styles.authTab__roleStatus,
styles[`authTab__roleStatus--${role.status.toLowerCase()}`]
)}>
{authStatusLabels[role.status] || role.status}
</span>
</div>
<div className={styles.authTab__roleInfo}>
<p><strong>:</strong> {role.regionName} ({role.regionCode})</p>
<p><strong>:</strong> {role.displayTitle}</p>
<p>
<strong>:</strong>
{role.benefitActive ? (
<span className={styles.authTab__benefitActive}></span>
) : (
<span className={styles.authTab__benefitInactive}></span>
)}
</p>
<p><strong>:</strong> {formatNumber(role.initialTargetTreeCount)} </p>
<p><strong>:</strong> {role.monthlyTargetType}</p>
<p><strong>:</strong> {formatDate(role.authorizedAt)}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* 月度考核记录 */}
<div className={styles.authTab__assessments}>
<h3></h3>
{authData.assessments.length === 0 ? (
<div className={styles.authTab__empty}></div>
) : (
<div className={styles.ledgerTable}>
<div className={styles.ledgerTable__header}>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}>/</div>
<div className={styles.ledgerTable__cell}>/</div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
</div>
{authData.assessments.map((assessment) => (
<div key={assessment.id} className={styles.ledgerTable__row}>
<div className={styles.ledgerTable__cell}>{assessment.assessmentMonth}</div>
<div className={styles.ledgerTable__cell}>
{roleTypeLabels[assessment.roleType] || assessment.roleType}
</div>
<div className={styles.ledgerTable__cell}>
{formatNumber(assessment.monthlyCompleted)} / {formatNumber(assessment.monthlyTarget)}
</div>
<div className={styles.ledgerTable__cell}>
{formatNumber(assessment.cumulativeCompleted)} / {formatNumber(assessment.cumulativeTarget)}
</div>
<div className={styles.ledgerTable__cell}>
<span className={cn(
styles.ledgerTable__result,
styles[`ledgerTable__result--${assessment.result.toLowerCase()}`]
)}>
{assessmentResultLabels[assessment.result] || assessment.result}
</span>
</div>
<div className={styles.ledgerTable__cell}>
{assessment.rankingInRegion || '-'}
{assessment.isFirstPlace && ' 🥇'}
</div>
</div>
))}
</div>
)}
</div>
{/* 系统账户流水(如果有) */}
{authData.systemAccountLedger.length > 0 && (
<div className={styles.authTab__systemLedger}>
<h3></h3>
<div className={styles.ledgerTable}>
<div className={styles.ledgerTable__header}>
<div className={styles.ledgerTable__cell}>ID</div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
</div>
{authData.systemAccountLedger.map((ledger) => (
<div key={ledger.ledgerId} className={styles.ledgerTable__row}>
<div className={styles.ledgerTable__cell}>{ledger.ledgerId}</div>
<div className={styles.ledgerTable__cell}>{ledger.accountType}</div>
<div className={styles.ledgerTable__cell}>{ledger.entryType}</div>
<div className={cn(
styles.ledgerTable__cell,
parseFloat(ledger.amount) >= 0
? styles['ledgerTable__cell--positive']
: styles['ledgerTable__cell--negative']
)}>
{parseFloat(ledger.amount) >= 0 ? '+' : ''}{formatAmount(ledger.amount)}
</div>
<div className={styles.ledgerTable__cell}>{formatAmount(ledger.balanceAfter)}</div>
<div className={styles.ledgerTable__cell}>{formatDate(ledger.createdAt)}</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<div className={styles.authTab__empty}></div>
)}
</div>
)}
</div>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,826 @@
/* 用户详情页面样式 */
@use '@/styles/variables' as *;
@use '@/styles/mixins' as *;
// ============================================================================
// 基础布局
// ============================================================================
.loading,
.error {
@include flex-center;
flex-direction: column;
min-height: 300px;
gap: $spacing-lg;
color: $text-secondary;
}
.error {
p {
color: $error-color;
margin: 0;
}
}
.userDetail {
@include flex-column;
gap: $spacing-xl;
width: 100%;
}
// ============================================================================
// 返回按钮
// ============================================================================
.userDetail__backBar {
margin-bottom: $spacing-sm;
}
.userDetail__backBtn {
@include flex-start;
gap: $spacing-sm;
padding: $spacing-sm $spacing-base;
border: none;
border-radius: $border-radius-base;
background: transparent;
color: $text-secondary;
font-size: $font-size-sm;
cursor: pointer;
@include transition-fast;
&:hover {
background: $background-color;
color: $primary-color;
}
}
.userDetail__backIcon {
font-size: $font-size-md;
}
// ============================================================================
// 基本信息卡片
// ============================================================================
.userDetail__basicCard {
@include card-base;
padding: $padding-card;
}
.userDetail__basicHeader {
@include flex-start;
gap: $spacing-xl;
margin-bottom: $spacing-xl;
padding-bottom: $spacing-xl;
border-bottom: 1px solid $border-color;
}
.userDetail__avatar {
width: 80px;
height: 80px;
border-radius: $border-radius-round;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-color: $background-color;
position: relative;
flex-shrink: 0;
}
.userDetail__status {
width: 16px;
height: 16px;
position: absolute;
right: 2px;
bottom: 2px;
border-radius: $border-radius-round;
box-shadow: 0 0 0 3px $card-background;
&--online {
background-color: $success-color;
}
&--offline {
background-color: $text-disabled;
}
}
.userDetail__basicInfo {
flex: 1;
}
.userDetail__nickname {
@include flex-start;
gap: $spacing-md;
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $text-primary;
margin: 0 0 $spacing-sm;
}
.userDetail__statusBadge {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
&--active {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&--frozen {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&--deactivated {
background-color: rgba($error-color, 0.1);
color: $error-color;
}
}
.userDetail__basicMeta {
@include flex-start;
flex-wrap: wrap;
gap: $spacing-lg;
font-size: $font-size-sm;
color: $text-secondary;
margin-bottom: $spacing-xs;
strong {
color: $text-primary;
font-weight: $font-weight-medium;
}
}
// ============================================================================
// 统计卡片
// ============================================================================
.userDetail__statsGrid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $spacing-base;
margin-bottom: $spacing-xl;
@include respond-below(xl) {
grid-template-columns: repeat(3, 1fr);
}
@include respond-below(md) {
grid-template-columns: repeat(2, 1fr);
}
}
.userDetail__statCard {
@include flex-column;
align-items: center;
justify-content: center;
padding: $spacing-lg;
background-color: $background-color;
border-radius: $border-radius-base;
text-align: center;
}
.userDetail__statLabel {
font-size: $font-size-xs;
color: $text-secondary;
margin-bottom: $spacing-sm;
}
.userDetail__statValue {
font-family: $font-family-number;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-primary;
&--gold {
color: #d4a537;
}
}
// ============================================================================
// 推荐人信息
// ============================================================================
.userDetail__referrerInfo {
@include flex-start;
flex-wrap: wrap;
gap: $spacing-md;
padding: $spacing-base;
background-color: rgba($primary-color, 0.05);
border-radius: $border-radius-base;
font-size: $font-size-sm;
}
.userDetail__referrerLabel {
color: $text-secondary;
}
.userDetail__referrerLink {
color: $primary-color;
font-weight: $font-weight-medium;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.userDetail__referrerMeta {
color: $text-secondary;
}
// ============================================================================
// Tab 导航
// ============================================================================
.userDetail__tabs {
@include flex-start;
gap: $spacing-xs;
padding: $spacing-xs;
background-color: $background-color;
border-radius: $border-radius-base;
}
.userDetail__tab {
padding: $spacing-sm $spacing-lg;
border: none;
border-radius: $border-radius-sm;
background: transparent;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-secondary;
cursor: pointer;
@include transition-fast;
&:hover {
color: $text-primary;
background-color: rgba($white, 0.5);
}
&--active {
background-color: $card-background;
color: $primary-color;
box-shadow: $shadow-sm;
}
}
.userDetail__tabContent {
@include card-base;
padding: $padding-card;
}
// ============================================================================
// 推荐关系 Tab
// ============================================================================
.referralTab {
@include flex-column;
gap: $spacing-xl;
}
.referralTab__header {
@include flex-between;
h3 {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
.referralTab__loading,
.referralTab__empty {
@include flex-center;
min-height: 200px;
color: $text-secondary;
}
// ============================================================================
// 推荐关系树
// ============================================================================
.referralTree {
@include flex-column;
align-items: center;
gap: $spacing-lg;
padding: $spacing-xl;
background-color: $background-color;
border-radius: $border-radius-lg;
}
.referralTree__ancestors {
@include flex-column;
align-items: center;
gap: $spacing-sm;
}
.referralTree__label {
font-size: $font-size-xs;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: $spacing-sm;
}
.referralTree__nodeList {
@include flex-column;
align-items: center;
gap: $spacing-sm;
}
.referralTree__nodeWrapper {
@include flex-column;
align-items: center;
}
.referralTree__connector {
color: $text-disabled;
font-size: $font-size-lg;
padding: $spacing-xs 0;
}
.referralTree__current {
@include flex-center;
}
.referralTree__node {
@include flex-column;
align-items: center;
padding: $spacing-base $spacing-xl;
background-color: $card-background;
border: 2px solid $border-color;
border-radius: $border-radius-base;
cursor: pointer;
@include transition-fast;
&:hover {
border-color: $primary-color;
box-shadow: $shadow-md;
transform: translateY(-2px);
}
&--current {
border-color: $primary-color;
background-color: rgba($primary-color, 0.05);
}
&--highlight {
border-width: 3px;
box-shadow: 0 0 0 4px rgba($primary-color, 0.2);
}
}
.referralTree__nodeSeq {
font-family: $font-family-number;
font-size: $font-size-md;
font-weight: $font-weight-bold;
color: $text-primary;
}
.referralTree__nodeNickname {
font-size: $font-size-xs;
color: $text-secondary;
margin-top: $spacing-xs;
}
.referralTree__nodeAdoptions {
font-size: $font-size-xs;
color: $text-secondary;
margin-top: $spacing-xs;
}
.referralTree__nodeCount {
font-size: $font-size-xs;
color: $primary-color;
margin-top: $spacing-xs;
font-weight: $font-weight-medium;
}
.referralTree__directReferrals {
@include flex-column;
align-items: center;
gap: $spacing-md;
width: 100%;
}
.referralTree__nodeGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: $spacing-md;
width: 100%;
max-width: 800px;
}
.referralTree__empty {
@include flex-center;
min-height: 100px;
color: $text-secondary;
}
// ============================================================================
// 认种信息 Tab
// ============================================================================
.plantingTab {
@include flex-column;
gap: $spacing-xl;
}
.plantingTab__loading,
.plantingTab__empty {
@include flex-center;
min-height: 200px;
color: $text-secondary;
}
.plantingTab__summary,
.plantingTab__ledger {
@include flex-column;
gap: $spacing-lg;
h3 {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
.plantingTab__summaryGrid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $spacing-base;
@include respond-below(xl) {
grid-template-columns: repeat(3, 1fr);
}
@include respond-below(md) {
grid-template-columns: repeat(2, 1fr);
}
}
.plantingTab__summaryItem {
@include flex-column;
padding: $spacing-base;
background-color: $background-color;
border-radius: $border-radius-base;
text-align: center;
}
.plantingTab__summaryLabel {
font-size: $font-size-xs;
color: $text-secondary;
margin-bottom: $spacing-sm;
}
.plantingTab__summaryValue {
font-family: $font-family-number;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-primary;
}
// ============================================================================
// 钱包信息 Tab
// ============================================================================
.walletTab {
@include flex-column;
gap: $spacing-xl;
}
.walletTab__loading,
.walletTab__empty {
@include flex-center;
min-height: 200px;
color: $text-secondary;
}
.walletTab__summary,
.walletTab__ledger {
@include flex-column;
gap: $spacing-lg;
h3 {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
.walletTab__summaryGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-base;
@include respond-below(lg) {
grid-template-columns: repeat(2, 1fr);
}
@include respond-below(sm) {
grid-template-columns: 1fr;
}
}
.walletTab__summaryItem {
@include flex-column;
padding: $spacing-base;
background-color: $background-color;
border-radius: $border-radius-base;
text-align: center;
}
.walletTab__summaryLabel {
font-size: $font-size-xs;
color: $text-secondary;
margin-bottom: $spacing-sm;
}
.walletTab__summaryValue {
font-family: $font-family-number;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-primary;
}
// ============================================================================
// 授权信息 Tab
// ============================================================================
.authTab {
@include flex-column;
gap: $spacing-xl;
}
.authTab__loading,
.authTab__empty {
@include flex-center;
min-height: 200px;
color: $text-secondary;
}
.authTab__roles,
.authTab__assessments,
.authTab__systemLedger {
@include flex-column;
gap: $spacing-lg;
h3 {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
.authTab__roleGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $spacing-base;
}
.authTab__roleCard {
@include flex-column;
padding: $spacing-lg;
background-color: $background-color;
border-radius: $border-radius-base;
border: 1px solid $border-color;
}
.authTab__roleHeader {
@include flex-between;
margin-bottom: $spacing-md;
padding-bottom: $spacing-md;
border-bottom: 1px solid $border-color;
}
.authTab__roleType {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.authTab__roleStatus {
padding: $spacing-xs $spacing-sm;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
&--pending {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&--authorized {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&--revoked {
background-color: rgba($error-color, 0.1);
color: $error-color;
}
&--expired {
background-color: rgba($text-disabled, 0.2);
color: $text-secondary;
}
}
.authTab__roleInfo {
@include flex-column;
gap: $spacing-xs;
p {
margin: 0;
font-size: $font-size-sm;
color: $text-secondary;
strong {
color: $text-primary;
margin-right: $spacing-xs;
}
}
}
.authTab__benefitActive {
color: $success-color;
font-weight: $font-weight-medium;
}
.authTab__benefitInactive {
color: $text-disabled;
}
// ============================================================================
// 通用分类账表格
// ============================================================================
.ledgerTable {
width: 100%;
overflow-x: auto;
@include custom-scrollbar;
}
.ledgerTable__header {
display: flex;
background-color: $background-color;
border-bottom: 2px solid $border-color;
min-width: 900px;
}
.ledgerTable__row {
display: flex;
border-bottom: 1px solid $border-color;
min-width: 900px;
@include transition-fast;
&:hover {
background-color: rgba($primary-color, 0.02);
}
&:last-child {
border-bottom: none;
}
}
.ledgerTable__cell {
flex: 1;
padding: $spacing-md $spacing-base;
font-size: $font-size-sm;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.ledgerTable__header & {
font-weight: $font-weight-medium;
color: $text-secondary;
text-transform: uppercase;
font-size: $font-size-xs;
}
&--positive {
color: $success-color;
}
&--negative {
color: $error-color;
}
}
.ledgerTable__empty {
@include flex-center;
min-height: 120px;
color: $text-secondary;
font-size: $font-size-sm;
}
.ledgerTable__status {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
&--created {
background-color: rgba($info-color, 0.1);
color: $info-color;
}
&--paid {
background-color: rgba($primary-color, 0.1);
color: $primary-color;
}
&--fund_allocated {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&--mining_enabled {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&--cancelled {
background-color: rgba($error-color, 0.1);
color: $error-color;
}
&--expired {
background-color: rgba($text-disabled, 0.2);
color: $text-secondary;
}
}
.ledgerTable__result {
display: inline-block;
padding: $spacing-xs $spacing-sm;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
&--not_assessed {
background-color: rgba($text-disabled, 0.2);
color: $text-secondary;
}
&--passed {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&--failed {
background-color: rgba($error-color, 0.1);
color: $error-color;
}
&--bypassed {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
}
// ============================================================================
// 分页
// ============================================================================
.pagination {
@include flex-center;
gap: $spacing-md;
padding-top: $spacing-lg;
button {
padding: $spacing-sm $spacing-base;
border: 1px solid $border-color;
border-radius: $border-radius-sm;
background-color: $card-background;
font-size: $font-size-sm;
color: $text-primary;
cursor: pointer;
@include transition-fast;
&:hover:not(:disabled) {
border-color: $primary-color;
color: $primary-color;
}
&:disabled {
color: $text-disabled;
cursor: not-allowed;
}
}
span {
font-size: $font-size-sm;
color: $text-secondary;
}
}

View File

@ -2,11 +2,12 @@
import { useState, useCallback } from 'react';
import Image from 'next/image';
import { Modal, toast, Button } from '@/components/common';
import Link from 'next/link';
import { toast, Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber, formatRanking } from '@/utils/formatters';
import { useUsers, useUserDetail } from '@/hooks';
import { useUsers } from '@/hooks';
import type { UserListItem } from '@/services/userService';
import styles from './users.module.scss';
@ -48,7 +49,6 @@ export default function UsersPage() {
const [keyword, setKeyword] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const [detailUserId, setDetailUserId] = useState<string | null>(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
@ -68,12 +68,6 @@ export default function UsersPage() {
sortOrder: 'desc',
});
// 获取用户详情
const {
data: userDetail,
isLoading: detailLoading,
} = useUserDetail(detailUserId || '');
const users = usersData?.items ?? [];
const total = usersData?.total ?? 0;
const totalPages = usersData?.totalPages ?? 1;
@ -107,16 +101,6 @@ export default function UsersPage() {
toast.success('导出功能开发中');
}, []);
// 查看详情
const handleViewDetail = useCallback((user: UserListItem) => {
setDetailUserId(user.accountId);
}, []);
// 关闭详情弹窗
const handleCloseDetail = useCallback(() => {
setDetailUserId(null);
}, []);
// 生成分页按钮
const renderPaginationButtons = () => {
const buttons = [];
@ -483,12 +467,12 @@ export default function UsersPage() {
{/* 操作 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
<button
<Link
href={`/users/${user.accountSequence || user.accountId}`}
className={styles.users__rowAction}
onClick={() => handleViewDetail(user)}
>
</button>
</Link>
</div>
</div>
))
@ -518,66 +502,6 @@ export default function UsersPage() {
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
</div>
</div>
{/* 用户详情弹窗 */}
<Modal
visible={!!detailUserId}
title="用户详情"
onClose={handleCloseDetail}
footer={null}
width={600}
>
{detailLoading ? (
<div className={styles.userDetail__loading}>...</div>
) : userDetail ? (
<div className={styles.userDetail}>
<div className={styles.userDetail__header}>
<div
className={styles.users__avatar}
style={{
backgroundImage: `url(${userDetail.avatar || '/images/Data@2x.png'})`,
width: 64,
height: 64,
}}
/>
<div className={styles.userDetail__info}>
<h3>{userDetail.nickname || '未设置昵称'}</h3>
<p>: {userDetail.accountSequence}</p>
<p>: {userDetail.phoneNumberMasked || '未绑定'}</p>
<p>KYC状态: {userDetail.kycStatus}</p>
</div>
</div>
<div className={styles.userDetail__stats}>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(userDetail.personalAdoptions)}
</span>
</div>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(userDetail.teamAdoptions)}
</span>
</div>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatRanking(userDetail.ranking)}
</span>
</div>
</div>
<div className={styles.userDetail__meta}>
<p>: {new Date(userDetail.registeredAt).toLocaleString()}</p>
{userDetail.lastActiveAt && (
<p>: {new Date(userDetail.lastActiveAt).toLocaleString()}</p>
)}
</div>
</div>
) : (
<div className={styles.userDetail__empty}></div>
)}
</Modal>
</div>
</PageContainer>
);

View File

@ -2,5 +2,6 @@
export * from './useDashboard';
export * from './useUsers';
export * from './useUserDetailPage';
export * from './useAuthorizations';
export * from './useSystemWithdrawal';

View File

@ -0,0 +1,99 @@
/**
* Hooks
*/
import { useQuery } from '@tanstack/react-query';
import { userDetailService } from '@/services/userDetailService';
import type {
LedgerQueryParams,
WalletLedgerQueryParams,
} from '@/types/userDetail.types';
// Query Keys
export const userDetailKeys = {
all: ['userDetail'] as const,
fullDetail: (accountSequence: string) => [...userDetailKeys.all, 'fullDetail', accountSequence] as const,
referralTree: (accountSequence: string, direction: string, depth: number) =>
[...userDetailKeys.all, 'referralTree', accountSequence, direction, depth] as const,
plantingLedger: (accountSequence: string, params: LedgerQueryParams) =>
[...userDetailKeys.all, 'plantingLedger', accountSequence, params] as const,
walletLedger: (accountSequence: string, params: WalletLedgerQueryParams) =>
[...userDetailKeys.all, 'walletLedger', accountSequence, params] as const,
authorizationDetail: (accountSequence: string) =>
[...userDetailKeys.all, 'authorizationDetail', accountSequence] as const,
};
/**
*
*/
export function useUserFullDetail(accountSequence: string) {
return useQuery({
queryKey: userDetailKeys.fullDetail(accountSequence),
queryFn: () => userDetailService.getFullDetail(accountSequence),
enabled: !!accountSequence,
staleTime: 60 * 1000, // 1分钟
gcTime: 5 * 60 * 1000, // 5分钟
});
}
/**
*
*/
export function useReferralTree(
accountSequence: string,
direction: 'up' | 'down' | 'both' = 'both',
depth: number = 1
) {
return useQuery({
queryKey: userDetailKeys.referralTree(accountSequence, direction, depth),
queryFn: () => userDetailService.getReferralTree(accountSequence, direction, depth),
enabled: !!accountSequence,
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/**
*
*/
export function usePlantingLedger(
accountSequence: string,
params: LedgerQueryParams = {}
) {
return useQuery({
queryKey: userDetailKeys.plantingLedger(accountSequence, params),
queryFn: () => userDetailService.getPlantingLedger(accountSequence, params),
enabled: !!accountSequence,
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/**
*
*/
export function useWalletLedger(
accountSequence: string,
params: WalletLedgerQueryParams = {}
) {
return useQuery({
queryKey: userDetailKeys.walletLedger(accountSequence, params),
queryFn: () => userDetailService.getWalletLedger(accountSequence, params),
enabled: !!accountSequence,
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/**
*
*/
export function useAuthorizationDetail(accountSequence: string) {
return useQuery({
queryKey: userDetailKeys.authorizationDetail(accountSequence),
queryFn: () => userDetailService.getAuthorizationDetail(accountSequence),
enabled: !!accountSequence,
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
});
}

View File

@ -21,6 +21,15 @@ export const API_ENDPOINTS = {
EXPORT: '/v1/admin/users/export',
},
// 用户详情页面 (admin-service) - 完整用户信息、推荐关系、分类账等
USER_DETAIL: {
FULL_DETAIL: (accountSequence: string) => `/v1/admin/users/${accountSequence}/full-detail`,
REFERRAL_TREE: (accountSequence: string) => `/v1/admin/users/${accountSequence}/referral-tree`,
PLANTING_LEDGER: (accountSequence: string) => `/v1/admin/users/${accountSequence}/planting-ledger`,
WALLET_LEDGER: (accountSequence: string) => `/v1/admin/users/${accountSequence}/wallet-ledger`,
AUTHORIZATION_DETAIL: (accountSequence: string) => `/v1/admin/users/${accountSequence}/authorization-detail`,
},
// 龙虎榜 (leaderboard-service)
LEADERBOARD: {
RANKINGS: '/v1/leaderboard/rankings',

View File

@ -0,0 +1,89 @@
/**
*
* API
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
import type {
UserFullDetail,
ReferralTreeData,
PlantingLedgerResponse,
WalletLedgerResponse,
AuthorizationDetailResponse,
LedgerQueryParams,
WalletLedgerQueryParams,
} from '@/types/userDetail.types';
/**
*
*/
export const userDetailService = {
/**
*
*/
async getFullDetail(accountSequence: string): Promise<UserFullDetail> {
return apiClient.get(API_ENDPOINTS.USER_DETAIL.FULL_DETAIL(accountSequence));
},
/**
*
* @param accountSequence
* @param direction up-down-both-
* @param depth 1
*/
async getReferralTree(
accountSequence: string,
direction: 'up' | 'down' | 'both' = 'both',
depth: number = 1
): Promise<ReferralTreeData> {
return apiClient.get(API_ENDPOINTS.USER_DETAIL.REFERRAL_TREE(accountSequence), {
params: { direction, depth },
});
},
/**
*
*/
async getPlantingLedger(
accountSequence: string,
params: LedgerQueryParams = {}
): Promise<PlantingLedgerResponse> {
return apiClient.get(API_ENDPOINTS.USER_DETAIL.PLANTING_LEDGER(accountSequence), {
params: {
page: params.page || 1,
pageSize: params.pageSize || 20,
startDate: params.startDate,
endDate: params.endDate,
},
});
},
/**
*
*/
async getWalletLedger(
accountSequence: string,
params: WalletLedgerQueryParams = {}
): Promise<WalletLedgerResponse> {
return apiClient.get(API_ENDPOINTS.USER_DETAIL.WALLET_LEDGER(accountSequence), {
params: {
page: params.page || 1,
pageSize: params.pageSize || 20,
assetType: params.assetType,
entryType: params.entryType,
startDate: params.startDate,
endDate: params.endDate,
},
});
},
/**
*
*/
async getAuthorizationDetail(accountSequence: string): Promise<AuthorizationDetailResponse> {
return apiClient.get(API_ENDPOINTS.USER_DETAIL.AUTHORIZATION_DETAIL(accountSequence));
},
};
export default userDetailService;

View File

@ -0,0 +1,322 @@
/**
*
*/
// ============================================================================
// 用户基本信息
// ============================================================================
export interface UserFullDetail {
// 基本信息
accountId: string;
accountSequence: string;
avatar: string | null;
nickname: string | null;
phoneNumberMasked: string | null;
status: 'active' | 'frozen' | 'deactivated';
kycStatus: string;
isOnline: boolean;
registeredAt: string;
lastActiveAt: string | null;
// 认种统计
personalAdoptions: number;
teamAddresses: number;
teamAdoptions: number;
provincialAdoptions: {
count: number;
percentage: number;
};
cityAdoptions: {
count: number;
percentage: number;
};
// 排名
ranking: number | null;
// 推荐信息
referralInfo: {
myReferralCode: string;
usedReferralCode: string | null;
referrerId: string | null;
referrerSequence: string | null;
referrerNickname: string | null;
ancestorPath: string | null;
depth: number;
directReferralCount: number;
activeDirectCount: number;
};
}
// ============================================================================
// 推荐关系树
// ============================================================================
export interface ReferralNode {
accountSequence: string;
userId: string;
nickname: string | null;
avatar: string | null;
personalAdoptions: number;
depth: number;
directReferralCount: number;
isCurrentUser?: boolean;
}
export interface ReferralTreeData {
currentUser: ReferralNode;
ancestors: ReferralNode[]; // 向上的推荐人链(从直接推荐人到最顶层)
directReferrals: ReferralNode[]; // 直推用户列表
}
// ============================================================================
// 认种信息
// ============================================================================
export interface PlantingSummary {
totalOrders: number;
totalTreeCount: number;
totalAmount: string;
effectiveTreeCount: number;
pendingTreeCount: number;
firstPlantingAt: string | null;
lastPlantingAt: string | null;
}
export interface PlantingLedgerItem {
orderId: string;
orderNo: string;
treeCount: number;
totalAmount: string;
status: string;
selectedProvince: string | null;
selectedCity: string | null;
createdAt: string;
paidAt: string | null;
fundAllocatedAt: string | null;
miningEnabledAt: string | null;
}
export interface PlantingLedgerResponse {
summary: PlantingSummary;
items: PlantingLedgerItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// 资金分配明细
export interface FundAllocationItem {
allocationId: string;
orderId: string;
orderNo: string;
targetType: string;
amount: string;
targetAccountId: string | null;
createdAt: string;
}
// ============================================================================
// 钱包信息
// ============================================================================
export interface WalletSummary {
// USDT
usdtAvailable: string;
usdtFrozen: string;
// DST
dstAvailable: string;
dstFrozen: string;
// BNB
bnbAvailable: string;
bnbFrozen: string;
// OG
ogAvailable: string;
ogFrozen: string;
// RWAD
rwadAvailable: string;
rwadFrozen: string;
// 算力
hashpower: string;
// 收益
pendingUsdt: string;
pendingHashpower: string;
settleableUsdt: string;
settleableHashpower: string;
settledTotalUsdt: string;
settledTotalHashpower: string;
expiredTotalUsdt: string;
expiredTotalHashpower: string;
}
export interface WalletLedgerItem {
entryId: string;
entryType: string;
assetType: string;
amount: string;
balanceAfter: string | null;
refOrderId: string | null;
refTxHash: string | null;
memo: string | null;
createdAt: string;
}
export interface WalletLedgerResponse {
summary: WalletSummary;
items: WalletLedgerItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// ============================================================================
// 授权信息
// ============================================================================
export interface AuthorizationRole {
id: string;
roleType: string;
regionCode: string;
regionName: string;
displayTitle: string;
status: string;
benefitActive: boolean;
benefitActivatedAt: string | null;
authorizedAt: string | null;
authorizedBy: string | null;
initialTargetTreeCount: number;
monthlyTargetType: string;
lastAssessmentMonth: string | null;
monthlyTreesAdded: number;
createdAt: string;
}
export interface MonthlyAssessment {
id: string;
authorizationId: string;
roleType: string;
regionCode: string;
assessmentMonth: string;
monthIndex: number;
monthlyTarget: number;
monthlyCompleted: number;
cumulativeTarget: number;
cumulativeCompleted: number;
result: string;
rankingInRegion: number | null;
isFirstPlace: boolean;
isBypassed: boolean;
completedAt: string | null;
assessedAt: string | null;
}
export interface SystemAccountLedgerItem {
ledgerId: string;
accountId: string;
accountType: string;
entryType: string;
amount: string;
balanceAfter: string;
sourceOrderId: string | null;
sourceRewardId: string | null;
txHash: string | null;
memo: string | null;
createdAt: string;
}
export interface AuthorizationDetailResponse {
roles: AuthorizationRole[];
assessments: MonthlyAssessment[];
systemAccountLedger: SystemAccountLedgerItem[];
}
// ============================================================================
// API 查询参数
// ============================================================================
export interface LedgerQueryParams {
page?: number;
pageSize?: number;
startDate?: string;
endDate?: string;
}
export interface WalletLedgerQueryParams extends LedgerQueryParams {
assetType?: string;
entryType?: string;
}
// ============================================================================
// 流水类型映射
// ============================================================================
export const WALLET_ENTRY_TYPE_LABELS: 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: '手续费',
};
export const ASSET_TYPE_LABELS: Record<string, string> = {
USDT: 'USDT',
DST: 'DST',
BNB: 'BNB',
OG: 'OG',
RWAD: 'RWAD',
HASHPOWER: '算力',
};
export const PLANTING_STATUS_LABELS: Record<string, string> = {
CREATED: '已创建',
PAID: '已支付',
FUND_ALLOCATED: '资金已分配',
MINING_ENABLED: '已开始挖矿',
CANCELLED: '已取消',
EXPIRED: '已过期',
};
export const AUTHORIZATION_ROLE_LABELS: Record<string, string> = {
COMMUNITY_PARTNER: '社区合伙人',
PROVINCE_COMPANY: '省公司',
CITY_COMPANY: '市公司',
AUTH_PROVINCE_COMPANY: '授权省公司',
AUTH_CITY_COMPANY: '授权市公司',
};
export const AUTHORIZATION_STATUS_LABELS: Record<string, string> = {
PENDING: '待授权',
AUTHORIZED: '已授权',
REVOKED: '已撤销',
EXPIRED: '已过期',
};
export const ASSESSMENT_RESULT_LABELS: Record<string, string> = {
NOT_ASSESSED: '未考核',
PASSED: '通过',
FAILED: '未通过',
BYPASSED: '豁免',
};