feat: 算力记录排序 + P2P转账管理后台查询端点
## 算力记录排序(全栈) - mining-admin-service controller: 新增 sortBy/sortOrder 查询参数 - users.service: 动态构建 orderBy(支持 sourceType, levelDepth, amount, createdAt) - levelDepth 排序特殊处理:先按 sourceType 分组,再按 levelDepth/bonusTier 排序,null 值排末尾 - admin-web contribution-records-list: 可点击排序表头(来源、获得算力、层级/等级、生效日期) - 三态切换:升序 → 降序 → 取消排序 - 排序图标指示当前状态 ## P2P转账管理后台查询端点(后端准备) - trading-service P2pTransferService: 新增 getAllTransfers() 方法 - 列出全部已完成 P2P 转账记录(分页 + 搜索) - 返回汇总统计:总手续费、总转账金额、总笔数 - trading-service controller: 新增 GET /p2p/internal/all-transfers(@Public 内部端点) - P2pTransferHistoryItem 接口扩展 fromPhone/fromNickname/toNickname 字段 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b1607666a0
commit
9f94344e8b
|
|
@ -52,15 +52,21 @@ export class UsersController {
|
||||||
@ApiParam({ name: 'accountSequence', type: String })
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'sortBy', required: false, type: String, description: '排序字段: sourceType, levelDepth, amount, createdAt' })
|
||||||
|
@ApiQuery({ name: 'sortOrder', required: false, type: String, description: '排序方向: asc, desc' })
|
||||||
async getUserContributions(
|
async getUserContributions(
|
||||||
@Param('accountSequence') accountSequence: string,
|
@Param('accountSequence') accountSequence: string,
|
||||||
@Query('page') page?: number,
|
@Query('page') page?: number,
|
||||||
@Query('pageSize') pageSize?: number,
|
@Query('pageSize') pageSize?: number,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('sortOrder') sortOrder?: string,
|
||||||
) {
|
) {
|
||||||
return this.usersService.getUserContributions(
|
return this.usersService.getUserContributions(
|
||||||
accountSequence,
|
accountSequence,
|
||||||
page ?? 1,
|
page ?? 1,
|
||||||
pageSize ?? 20,
|
pageSize ?? 20,
|
||||||
|
sortBy,
|
||||||
|
sortOrder as 'asc' | 'desc' | undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,8 @@ export class UsersService {
|
||||||
accountSequence: string,
|
accountSequence: string,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: 'asc' | 'desc',
|
||||||
) {
|
) {
|
||||||
const user = await this.prisma.syncedUser.findUnique({
|
const user = await this.prisma.syncedUser.findUnique({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
|
|
@ -347,11 +349,32 @@ export class UsersService {
|
||||||
unlockedBonusTiers: 0,
|
unlockedBonusTiers: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 构建排序条件
|
||||||
|
const allowedSortFields = ['sourceType', 'levelDepth', 'amount', 'createdAt'];
|
||||||
|
const order = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'desc';
|
||||||
|
let orderBy: any;
|
||||||
|
|
||||||
|
if (sortBy && allowedSortFields.includes(sortBy)) {
|
||||||
|
// 按指定字段排序,levelDepth 需要特殊处理(null 值排到末尾)
|
||||||
|
if (sortBy === 'levelDepth') {
|
||||||
|
// 先按 sourceType 分组,再按 levelDepth/bonusTier 排序
|
||||||
|
orderBy = [
|
||||||
|
{ sourceType: order },
|
||||||
|
{ levelDepth: { sort: order, nulls: 'last' } },
|
||||||
|
{ bonusTier: { sort: order, nulls: 'last' } },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
orderBy = { [sortBy]: order };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orderBy = { createdAt: 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
// 获取算力明细记录
|
// 获取算力明细记录
|
||||||
const [records, total] = await Promise.all([
|
const [records, total] = await Promise.all([
|
||||||
this.prisma.syncedContributionRecord.findMany({
|
this.prisma.syncedContributionRecord.findMany({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestExce
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
class P2pTransferDto {
|
class P2pTransferDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -81,4 +82,22 @@ export class P2pTransferController {
|
||||||
// TransformInterceptor 会自动包装成 { success: true, data: ... }
|
// TransformInterceptor 会自动包装成 { success: true, data: ... }
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('internal/all-transfers')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取全部P2P转账记录(内部调用,管理后台用)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
async getAllTransfers(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
) {
|
||||||
|
return this.p2pTransferService.getAllTransfers(
|
||||||
|
page ?? 1,
|
||||||
|
pageSize ?? 20,
|
||||||
|
search,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,11 @@ export interface P2pTransferResult {
|
||||||
export interface P2pTransferHistoryItem {
|
export interface P2pTransferHistoryItem {
|
||||||
transferNo: string;
|
transferNo: string;
|
||||||
fromAccountSequence: string;
|
fromAccountSequence: string;
|
||||||
|
fromPhone?: string | null;
|
||||||
|
fromNickname?: string | null;
|
||||||
toAccountSequence: string;
|
toAccountSequence: string;
|
||||||
toPhone: string;
|
toPhone: string;
|
||||||
|
toNickname?: string | null;
|
||||||
amount: string;
|
amount: string;
|
||||||
fee?: string;
|
fee?: string;
|
||||||
memo?: string | null;
|
memo?: string | null;
|
||||||
|
|
@ -410,6 +413,73 @@ export class P2pTransferService {
|
||||||
return { data, total };
|
return { data, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部P2P转账记录(管理后台用,不限用户)
|
||||||
|
*/
|
||||||
|
async getAllTransfers(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
search?: string,
|
||||||
|
): Promise<{
|
||||||
|
data: P2pTransferHistoryItem[];
|
||||||
|
total: number;
|
||||||
|
summary: { totalFee: string; totalAmount: string; totalCount: number };
|
||||||
|
}> {
|
||||||
|
const validPage = Math.max(1, Number(page) || 1);
|
||||||
|
const validPageSize = Math.max(1, Math.min(100, Number(pageSize) || 20));
|
||||||
|
|
||||||
|
const where: any = { status: 'COMPLETED' };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ fromAccountSequence: { contains: search } },
|
||||||
|
{ toAccountSequence: { contains: search } },
|
||||||
|
{ toPhone: { contains: search } },
|
||||||
|
{ fromPhone: { contains: search } },
|
||||||
|
{ transferNo: { contains: search } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total, agg] = await Promise.all([
|
||||||
|
this.prisma.p2pTransfer.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (validPage - 1) * validPageSize,
|
||||||
|
take: validPageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.p2pTransfer.count({ where }),
|
||||||
|
this.prisma.p2pTransfer.aggregate({
|
||||||
|
where: { status: 'COMPLETED' },
|
||||||
|
_sum: { fee: true, amount: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: P2pTransferHistoryItem[] = records.map((record) => ({
|
||||||
|
transferNo: record.transferNo,
|
||||||
|
fromAccountSequence: record.fromAccountSequence,
|
||||||
|
fromPhone: record.fromPhone,
|
||||||
|
fromNickname: record.fromNickname,
|
||||||
|
toAccountSequence: record.toAccountSequence,
|
||||||
|
toPhone: record.toPhone,
|
||||||
|
toNickname: record.toNickname,
|
||||||
|
amount: record.amount.toString(),
|
||||||
|
fee: record.fee.toString(),
|
||||||
|
memo: record.memo,
|
||||||
|
status: record.status,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
summary: {
|
||||||
|
totalFee: agg._sum.fee?.toString() || '0',
|
||||||
|
totalAmount: agg._sum.amount?.toString() || '0',
|
||||||
|
totalCount: agg._count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private generateTransferNo(): string {
|
private generateTransferNo(): string {
|
||||||
const timestamp = Date.now().toString(36);
|
const timestamp = Date.now().toString(36);
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export const usersApi = {
|
||||||
|
|
||||||
getContributionRecords: async (
|
getContributionRecords: async (
|
||||||
accountSequence: string,
|
accountSequence: string,
|
||||||
params: PaginationParams
|
params: PaginationParams & { sortBy?: string; sortOrder?: 'asc' | 'desc' }
|
||||||
): Promise<PaginatedResponse<ContributionRecord>> => {
|
): Promise<PaginatedResponse<ContributionRecord>> => {
|
||||||
const response = await apiClient.get(`/users/${accountSequence}/contributions`, { params });
|
const response = await apiClient.get(`/users/${accountSequence}/contributions`, { params });
|
||||||
const result = response.data.data;
|
const result = response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
|
type SortField = 'sourceType' | 'levelDepth' | 'amount' | 'createdAt';
|
||||||
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
const sourceTypeLabels: Record<string, string> = {
|
const sourceTypeLabels: Record<string, string> = {
|
||||||
PERSONAL: '个人认种',
|
PERSONAL: '个人认种',
|
||||||
|
|
@ -23,6 +26,43 @@ const sourceTypeBadgeVariant: Record<string, 'default' | 'secondary' | 'outline'
|
||||||
TEAM_BONUS: 'outline',
|
TEAM_BONUS: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
currentSort,
|
||||||
|
currentOrder,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
currentSort?: SortField;
|
||||||
|
currentOrder: SortOrder;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isActive = currentSort === field;
|
||||||
|
return (
|
||||||
|
<TableHead className={className}>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 hover:text-foreground transition-colors -ml-1 px-1 py-0.5 rounded"
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{isActive ? (
|
||||||
|
currentOrder === 'asc' ? (
|
||||||
|
<ArrowUp className="h-3.5 w-3.5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ContributionRecordsListProps {
|
interface ContributionRecordsListProps {
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
}
|
}
|
||||||
|
|
@ -30,8 +70,31 @@ interface ContributionRecordsListProps {
|
||||||
export function ContributionRecordsList({ accountSequence }: ContributionRecordsListProps) {
|
export function ContributionRecordsList({ accountSequence }: ContributionRecordsListProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
const [sortBy, setSortBy] = useState<SortField | undefined>(undefined);
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||||
|
|
||||||
const { data, isLoading } = useContributionRecords(accountSequence, { page, pageSize });
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
// 同一字段:切换方向 → 第三次点击取消排序
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
setSortOrder('desc');
|
||||||
|
} else {
|
||||||
|
setSortBy(undefined);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortBy(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading } = useContributionRecords(accountSequence, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy,
|
||||||
|
sortOrder: sortBy ? sortOrder : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -39,14 +102,14 @@ export function ContributionRecordsList({ accountSequence }: ContributionRecords
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>来源</TableHead>
|
<SortableHeader label="来源" field="sourceType" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||||
<TableHead>来源用户</TableHead>
|
<TableHead>来源用户</TableHead>
|
||||||
<TableHead className="text-right">棵数</TableHead>
|
<TableHead className="text-right">棵数</TableHead>
|
||||||
<TableHead className="text-right">基础算力</TableHead>
|
<TableHead className="text-right">基础算力</TableHead>
|
||||||
<TableHead className="text-right">分配比例</TableHead>
|
<TableHead className="text-right">分配比例</TableHead>
|
||||||
<TableHead className="text-right">获得算力</TableHead>
|
<SortableHeader label="获得算力" field="amount" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} className="text-right" />
|
||||||
<TableHead>层级/等级</TableHead>
|
<SortableHeader label="层级/等级" field="levelDepth" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||||
<TableHead>生效日期</TableHead>
|
<SortableHeader label="生效日期" field="createdAt" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export function useUserDetail(accountSequence: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContributionRecords(accountSequence: string, params: PaginationParams) {
|
export function useContributionRecords(accountSequence: string, params: PaginationParams & { sortBy?: string; sortOrder?: 'asc' | 'desc' }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['users', accountSequence, 'contributions', params],
|
queryKey: ['users', accountSequence, 'contributions', params],
|
||||||
queryFn: () => usersApi.getContributionRecords(accountSequence, params),
|
queryFn: () => usersApi.getContributionRecords(accountSequence, params),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue