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 })
|
||||
@ApiQuery({ name: 'page', 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(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('pageSize') pageSize?: number,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: string,
|
||||
) {
|
||||
return this.usersService.getUserContributions(
|
||||
accountSequence,
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
sortBy,
|
||||
sortOrder as 'asc' | 'desc' | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ export class UsersService {
|
|||
accountSequence: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortBy?: string,
|
||||
sortOrder?: 'asc' | 'desc',
|
||||
) {
|
||||
const user = await this.prisma.syncedUser.findUnique({
|
||||
where: { accountSequence },
|
||||
|
|
@ -347,11 +349,32 @@ export class UsersService {
|
|||
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([
|
||||
this.prisma.syncedContributionRecord.findMany({
|
||||
where: { accountSequence },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy,
|
||||
skip: (page - 1) * 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 { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
class P2pTransferDto {
|
||||
@IsString()
|
||||
|
|
@ -81,4 +82,22 @@ export class P2pTransferController {
|
|||
// TransformInterceptor 会自动包装成 { success: true, 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 {
|
||||
transferNo: string;
|
||||
fromAccountSequence: string;
|
||||
fromPhone?: string | null;
|
||||
fromNickname?: string | null;
|
||||
toAccountSequence: string;
|
||||
toPhone: string;
|
||||
toNickname?: string | null;
|
||||
amount: string;
|
||||
fee?: string;
|
||||
memo?: string | null;
|
||||
|
|
@ -410,6 +413,73 @@ export class P2pTransferService {
|
|||
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 {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const usersApi = {
|
|||
|
||||
getContributionRecords: async (
|
||||
accountSequence: string,
|
||||
params: PaginationParams
|
||||
params: PaginationParams & { sortBy?: string; sortOrder?: 'asc' | 'desc' }
|
||||
): Promise<PaginatedResponse<ContributionRecord>> => {
|
||||
const response = await apiClient.get(`/users/${accountSequence}/contributions`, { params });
|
||||
const result = response.data.data;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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> = {
|
||||
PERSONAL: '个人认种',
|
||||
|
|
@ -23,6 +26,43 @@ const sourceTypeBadgeVariant: Record<string, 'default' | 'secondary' | '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 {
|
||||
accountSequence: string;
|
||||
}
|
||||
|
|
@ -30,8 +70,31 @@ interface ContributionRecordsListProps {
|
|||
export function ContributionRecordsList({ accountSequence }: ContributionRecordsListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
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 (
|
||||
<Card>
|
||||
|
|
@ -39,14 +102,14 @@ export function ContributionRecordsList({ accountSequence }: ContributionRecords
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>来源</TableHead>
|
||||
<SortableHeader label="来源" field="sourceType" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||
<TableHead>来源用户</TableHead>
|
||||
<TableHead className="text-right">棵数</TableHead>
|
||||
<TableHead className="text-right">基础算力</TableHead>
|
||||
<TableHead className="text-right">分配比例</TableHead>
|
||||
<TableHead className="text-right">获得算力</TableHead>
|
||||
<TableHead>层级/等级</TableHead>
|
||||
<TableHead>生效日期</TableHead>
|
||||
<SortableHeader label="获得算力" field="amount" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} className="text-right" />
|
||||
<SortableHeader label="层级/等级" field="levelDepth" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||
<SortableHeader label="生效日期" field="createdAt" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</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({
|
||||
queryKey: ['users', accountSequence, 'contributions', params],
|
||||
queryFn: () => usersApi.getContributionRecords(accountSequence, params),
|
||||
|
|
|
|||
Loading…
Reference in New Issue