feat(admin): 系统账户添加已挖积分股显示和分类账功能

## 后端改动

### mining-service
- 新增 GET /admin/system-accounts/:accountType/records - 获取系统账户挖矿记录(分钟级)
- 新增 GET /admin/system-accounts/:accountType/transactions - 获取系统账户交易记录

### mining-admin-service
- 添加 @nestjs/axios 依赖用于 HTTP 调用
- 修改 SystemAccountsService,通过 HTTP 调用 mining-service 获取挖矿数据(totalMined, availableBalance)
- 新增挖矿记录和交易记录的代理 API

## 前端改动

### 类型定义
- SystemAccount 新增 totalMined, availableBalance, miningContribution, miningLastSyncedAt 字段

### API 层
- 新增 getMiningRecords 和 getTransactions API 方法
- 新增 SystemMiningRecord, SystemTransaction 等类型定义

### Hooks
- 新增 useSystemAccountMiningRecords 和 useSystemAccountTransactions

### 组件
- AccountsTable 新增"已挖积分股"列,显示每个系统账户累计挖到的积分股
- AccountsTable 新增"分类账"按钮,可跳转到账户详情页

### 新页面
- 新建 /system-accounts/[accountType] 详情页面
  - 账户概览卡片:当前算力、已挖积分股、可用余额、挖矿记录数
  - 挖矿记录 Tab:分钟级挖矿明细(时间、算力占比、全网算力、每秒分配量、挖得数量)
  - 交易记录 Tab:所有交易流水(时间、类型、金额、交易前后余额、备注)
  - 支持分页浏览

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 05:53:03 -08:00
parent 07498271d3
commit d957e5a841
12 changed files with 1020 additions and 17 deletions

View File

@ -8,12 +8,14 @@
"name": "mining-admin-service",
"version": "1.0.0",
"dependencies": {
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -627,6 +629,17 @@
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT"
},
"node_modules/@nestjs/axios": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz",
"integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"axios": "^1.3.1",
"rxjs": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@ -1734,6 +1747,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2212,6 +2243,18 @@
"color-support": "bin.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -2433,6 +2476,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@ -2629,6 +2681,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -3136,6 +3203,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -3182,6 +3269,22 @@
"webpack": "^5.11.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -3493,6 +3596,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@ -4878,6 +4996,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -15,12 +15,14 @@
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",

View File

@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { SystemAccountsService } from '../../application/services/system-accounts.service';
@ApiTags('System Accounts')
@ -19,4 +19,38 @@ export class SystemAccountsController {
async getSystemAccountsSummary() {
return this.systemAccountsService.getSystemAccountsSummary();
}
@Get(':accountType/records')
@ApiOperation({ summary: '获取系统账户挖矿记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountMiningRecords(
@Param('accountType') accountType: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.systemAccountsService.getSystemAccountMiningRecords(
accountType,
page ?? 1,
pageSize ?? 20,
);
}
@Get(':accountType/transactions')
@ApiOperation({ summary: '获取系统账户交易记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountTransactions(
@Param('accountType') accountType: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.systemAccountsService.getSystemAccountTransactions(
accountType,
page ?? 1,
pageSize ?? 20,
);
}
}

View File

@ -1,13 +1,66 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
interface MiningServiceSystemAccount {
accountType: string;
name: string;
totalMined: string;
availableBalance: string;
totalContribution: string;
lastSyncedAt: string | null;
}
interface MiningServiceResponse {
accounts: MiningServiceSystemAccount[];
total: number;
}
@Injectable()
export class SystemAccountsService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(SystemAccountsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
/**
* mining-service
*/
private async fetchMiningServiceSystemAccounts(): Promise<Map<string, MiningServiceSystemAccount>> {
const miningServiceUrl = this.configService.get<string>(
'MINING_SERVICE_URL',
'http://localhost:3021',
);
try {
const response = await firstValueFrom(
this.httpService.get<MiningServiceResponse>(
`${miningServiceUrl}/admin/system-accounts`,
),
);
const miningDataMap = new Map<string, MiningServiceSystemAccount>();
for (const account of response.data.accounts) {
miningDataMap.set(account.accountType, account);
}
return miningDataMap;
} catch (error) {
this.logger.warn(
`Failed to fetch mining service system accounts: ${error.message}`,
);
return new Map();
}
}
/**
*
* CDC
* CDC
*/
async getSystemAccounts() {
// 从 CDC 同步的 SyncedWalletSystemAccount 表获取数据
@ -19,6 +72,9 @@ export class SystemAccountsService {
const syncedContributions =
await this.prisma.syncedSystemContribution.findMany();
// 从 mining-service 获取挖矿数据
const miningDataMap = await this.fetchMiningServiceSystemAccounts();
// 构建算力数据映射
const contributionMap = new Map<string, any>();
for (const contrib of syncedContributions) {
@ -28,6 +84,8 @@ export class SystemAccountsService {
// 构建返回数据
const accounts = syncedAccounts.map((account) => {
const contrib = contributionMap.get(account.accountType);
const miningData = miningDataMap.get(account.accountType);
return {
id: account.originalId,
accountType: account.accountType,
@ -46,6 +104,11 @@ export class SystemAccountsService {
isActive: account.isActive,
contributionBalance: contrib?.contributionBalance?.toString() || '0',
contributionNeverExpires: contrib?.contributionNeverExpires || false,
// 挖矿数据
totalMined: miningData?.totalMined || '0',
availableBalance: miningData?.availableBalance || '0',
miningContribution: miningData?.totalContribution || '0',
miningLastSyncedAt: miningData?.lastSyncedAt || null,
syncedAt: account.syncedAt,
source: 'cdc',
};
@ -75,6 +138,15 @@ export class SystemAccountsService {
this.prisma.syncedCirculationPool.findFirst(),
]);
// 从 mining-service 获取挖矿数据汇总
const miningDataMap = await this.fetchMiningServiceSystemAccounts();
// 计算总挖矿积分股
let totalMined = 0;
for (const miningData of miningDataMap.values()) {
totalMined += Number(miningData.totalMined || 0);
}
// 计算总算力
let totalSyncedContribution = 0n;
for (const contrib of syncedContributions) {
@ -90,6 +162,7 @@ export class SystemAccountsService {
(sum, acc) => sum + Number(acc.shareBalance),
0,
).toFixed(8),
totalMined: totalMined.toFixed(8),
},
poolAccounts: {
count: syncedPoolAccounts.length,
@ -123,4 +196,68 @@ export class SystemAccountsService {
: null,
};
}
/**
*
*/
async getSystemAccountMiningRecords(
accountType: string,
page: number = 1,
pageSize: number = 20,
) {
const miningServiceUrl = this.configService.get<string>(
'MINING_SERVICE_URL',
'http://localhost:3021',
);
try {
const response = await firstValueFrom(
this.httpService.get(
`${miningServiceUrl}/admin/system-accounts/${accountType}/records`,
{
params: { page, pageSize },
},
),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch system account mining records: ${error.message}`,
);
return { records: [], total: 0, page, pageSize };
}
}
/**
*
*/
async getSystemAccountTransactions(
accountType: string,
page: number = 1,
pageSize: number = 20,
) {
const miningServiceUrl = this.configService.get<string>(
'MINING_SERVICE_URL',
'http://localhost:3021',
);
try {
const response = await firstValueFrom(
this.httpService.get(
`${miningServiceUrl}/admin/system-accounts/${accountType}/transactions`,
{
params: { page, pageSize },
},
),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch system account transactions: ${error.message}`,
);
return { transactions: [], total: 0, page, pageSize };
}
}
}

View File

@ -1,12 +1,20 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { PrismaModule } from './persistence/prisma/prisma.module';
import { RedisService } from './redis/redis.service';
import { KafkaModule } from './kafka/kafka.module';
@Global()
@Module({
imports: [PrismaModule, KafkaModule],
imports: [
PrismaModule,
KafkaModule,
HttpModule.register({
timeout: 10000,
maxRedirects: 5,
}),
],
providers: [
{
provide: 'REDIS_OPTIONS',
@ -20,6 +28,6 @@ import { KafkaModule } from './kafka/kafka.module';
},
RedisService,
],
exports: [PrismaModule, RedisService, KafkaModule],
exports: [PrismaModule, RedisService, KafkaModule, HttpModule],
})
export class InfrastructureModule {}

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { SystemAccountType } from '@prisma/client';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { NetworkSyncService } from '../../application/services/network-sync.service';
import { ManualMiningService } from '../../application/services/manual-mining.service';
@ -179,6 +180,98 @@ export class AdminController {
};
}
@Get('system-accounts/:accountType/records')
@Public()
@ApiOperation({ summary: '获取系统账户挖矿记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE, CITY, HEADQUARTERS)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountMiningRecords(
@Param('accountType') accountType: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const pageNum = page ?? 1;
const pageSizeNum = pageSize ?? 20;
const skip = (pageNum - 1) * pageSizeNum;
const accountTypeEnum = accountType as SystemAccountType;
const [records, total] = await Promise.all([
this.prisma.systemMiningRecord.findMany({
where: { accountType: accountTypeEnum },
orderBy: { miningMinute: 'desc' },
skip,
take: pageSizeNum,
}),
this.prisma.systemMiningRecord.count({
where: { accountType: accountTypeEnum },
}),
]);
return {
records: records.map((record) => ({
id: record.id,
accountType: record.accountType,
miningMinute: record.miningMinute,
contributionRatio: record.contributionRatio.toString(),
totalContribution: record.totalContribution.toString(),
secondDistribution: record.secondDistribution.toString(),
minedAmount: record.minedAmount.toString(),
createdAt: record.createdAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
};
}
@Get('system-accounts/:accountType/transactions')
@Public()
@ApiOperation({ summary: '获取系统账户交易记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE, CITY, HEADQUARTERS)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountTransactions(
@Param('accountType') accountType: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const pageNum = page ?? 1;
const pageSizeNum = pageSize ?? 20;
const skip = (pageNum - 1) * pageSizeNum;
const accountTypeEnum = accountType as SystemAccountType;
const [transactions, total] = await Promise.all([
this.prisma.systemMiningTransaction.findMany({
where: { accountType: accountTypeEnum },
orderBy: { createdAt: 'desc' },
skip,
take: pageSizeNum,
}),
this.prisma.systemMiningTransaction.count({
where: { accountType: accountTypeEnum },
}),
]);
return {
transactions: transactions.map((tx) => ({
id: tx.id,
accountType: tx.accountType,
type: tx.type,
amount: tx.amount.toString(),
balanceBefore: tx.balanceBefore.toString(),
balanceAfter: tx.balanceAfter.toString(),
referenceId: tx.referenceId,
referenceType: tx.referenceType,
memo: tx.memo,
createdAt: tx.createdAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
};
}
@Get('mining/status')
@Public()
@ApiOperation({ summary: '获取挖矿进度状态(类似销毁进度)' })

View File

@ -0,0 +1,464 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertCircle,
RefreshCw,
Pickaxe,
Receipt,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import {
useSystemAccounts,
useSystemAccountMiningRecords,
useSystemAccountTransactions,
} from '@/features/system-accounts';
import { getAccountDisplayInfo } from '@/types/system-account';
import { formatDecimal } from '@/lib/utils/format';
const TRANSACTION_TYPE_LABELS: Record<string, { label: string; color: string }> = {
MINE: { label: '挖矿收益', color: 'bg-green-100 text-green-800' },
TRANSFER_OUT: { label: '转出', color: 'bg-red-100 text-red-800' },
TRANSFER_IN: { label: '转入', color: 'bg-blue-100 text-blue-800' },
ADJUSTMENT: { label: '调整', color: 'bg-yellow-100 text-yellow-800' },
};
export default function SystemAccountDetailPage() {
const params = useParams();
const queryClient = useQueryClient();
const accountType = params.accountType as string;
const [miningPage, setMiningPage] = useState(1);
const [transactionPage, setTransactionPage] = useState(1);
const pageSize = 20;
// 获取账户列表以找到当前账户信息
const { data: accountsData, isLoading: accountsLoading } = useSystemAccounts();
const currentAccount = accountsData?.accounts.find(
(a) => a.accountType === accountType
);
// 获取挖矿记录
const {
data: miningRecords,
isLoading: miningLoading,
error: miningError,
} = useSystemAccountMiningRecords(accountType, miningPage, pageSize);
// 获取交易记录
const {
data: transactions,
isLoading: transactionsLoading,
error: transactionsError,
} = useSystemAccountTransactions(accountType, transactionPage, pageSize);
const displayInfo = getAccountDisplayInfo(accountType);
const handleRefresh = () => {
queryClient.invalidateQueries({
queryKey: ['system-accounts', accountType],
});
};
const miningTotalPages = miningRecords
? Math.ceil(miningRecords.total / pageSize)
: 0;
const transactionTotalPages = transactions
? Math.ceil(transactions.total / pageSize)
: 0;
const accountTitle = currentAccount?.name || displayInfo.label;
return (
<div className="space-y-6">
{/* 自定义头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/system-accounts">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<span
className={`inline-block w-3 h-3 rounded-full ${displayInfo.color.replace('text-', 'bg-')}`}
/>
<h1 className="text-2xl font-bold tracking-tight">{accountTitle}</h1>
<code className="text-sm bg-muted px-2 py-1 rounded font-normal">
{accountType}
</code>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={miningLoading || transactionsLoading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${miningLoading || transactionsLoading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
{/* 账户概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{accountsLoading ? (
<Skeleton className="h-8 w-32" />
) : (
<div className="text-2xl font-bold">
{formatDecimal(
currentAccount?.contributionBalance ||
currentAccount?.totalContribution ||
'0',
2
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{accountsLoading ? (
<Skeleton className="h-8 w-32" />
) : (
<div className="text-2xl font-bold text-green-600">
{formatDecimal(currentAccount?.totalMined || '0', 8)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{accountsLoading ? (
<Skeleton className="h-8 w-32" />
) : (
<div className="text-2xl font-bold">
{formatDecimal(currentAccount?.availableBalance || '0', 8)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{miningLoading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">{miningRecords?.total || 0}</div>
)}
</CardContent>
</Card>
</div>
{/* 分类账 Tabs */}
<Tabs defaultValue="mining" className="space-y-4">
<TabsList>
<TabsTrigger value="mining" className="flex items-center gap-2">
<Pickaxe className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="transactions" className="flex items-center gap-2">
<Receipt className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 挖矿记录 Tab */}
<TabsContent value="mining">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{miningRecords && (
<Badge variant="secondary" className="text-xs">
{miningRecords.total}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{miningError ? (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{miningLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(5)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !miningRecords?.records.length ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-8"
>
</TableCell>
</TableRow>
) : (
miningRecords.records.map((record) => (
<TableRow key={record.id}>
<TableCell>
{format(
new Date(record.miningMinute),
'yyyy-MM-dd HH:mm',
{ locale: zhCN }
)}
</TableCell>
<TableCell className="text-right font-mono">
{(Number(record.contributionRatio) * 100).toFixed(6)}%
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(record.totalContribution, 2)}
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(record.secondDistribution, 8)}
</TableCell>
<TableCell className="text-right font-mono text-green-600">
+{formatDecimal(record.minedAmount, 8)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
{miningTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setMiningPage((p) => Math.max(1, p - 1))}
disabled={miningPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{miningPage} / {miningTotalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setMiningPage((p) => Math.min(miningTotalPages, p + 1))
}
disabled={miningPage >= miningTotalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 交易记录 Tab */}
<TabsContent value="transactions">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{transactions && (
<Badge variant="secondary" className="text-xs">
{transactions.total}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{transactionsError ? (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactionsLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(6)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !transactions?.transactions.length ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-8"
>
</TableCell>
</TableRow>
) : (
transactions.transactions.map((tx) => {
const typeInfo = TRANSACTION_TYPE_LABELS[tx.type] || {
label: tx.type,
color: 'bg-gray-100 text-gray-800',
};
const isPositive = Number(tx.amount) > 0;
return (
<TableRow key={tx.id}>
<TableCell>
{format(
new Date(tx.createdAt),
'yyyy-MM-dd HH:mm:ss',
{ locale: zhCN }
)}
</TableCell>
<TableCell>
<Badge className={`${typeInfo.color} text-xs`}>
{typeInfo.label}
</Badge>
</TableCell>
<TableCell
className={`text-right font-mono ${
isPositive ? 'text-green-600' : 'text-red-600'
}`}
>
{isPositive ? '+' : ''}
{formatDecimal(tx.amount, 8)}
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(tx.balanceBefore, 8)}
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(tx.balanceAfter, 8)}
</TableCell>
<TableCell className="text-muted-foreground text-sm max-w-[200px] truncate">
{tx.memo || '-'}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* 分页 */}
{transactionTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() =>
setTransactionPage((p) => Math.max(1, p - 1))
}
disabled={transactionPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{transactionPage} / {transactionTotalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setTransactionPage((p) =>
Math.min(transactionTotalPages, p + 1)
)
}
disabled={transactionPage >= transactionTotalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -5,6 +5,44 @@ import type {
SystemAccountsSummary,
} from '@/types/system-account';
export interface SystemMiningRecord {
id: string;
accountType: string;
miningMinute: string;
contributionRatio: string;
totalContribution: string;
secondDistribution: string;
minedAmount: string;
createdAt: string;
}
export interface SystemMiningRecordsResponse {
records: SystemMiningRecord[];
total: number;
page: number;
pageSize: number;
}
export interface SystemTransaction {
id: string;
accountType: string;
type: string;
amount: string;
balanceBefore: string;
balanceAfter: string;
referenceId?: string;
referenceType?: string;
memo?: string;
createdAt: string;
}
export interface SystemTransactionsResponse {
transactions: SystemTransaction[];
total: number;
page: number;
pageSize: number;
}
export const systemAccountsApi = {
/**
* Get all system accounts (merged local + synced data)
@ -21,6 +59,36 @@ export const systemAccountsApi = {
const response = await apiClient.get('/system-accounts/summary');
return response.data.data;
},
/**
* Get system account mining records
*/
getMiningRecords: async (
accountType: string,
page: number = 1,
pageSize: number = 20
): Promise<SystemMiningRecordsResponse> => {
const response = await apiClient.get(
`/system-accounts/${accountType}/records`,
{ params: { page, pageSize } }
);
return response.data.data;
},
/**
* Get system account transactions
*/
getTransactions: async (
accountType: string,
page: number = 1,
pageSize: number = 20
): Promise<SystemTransactionsResponse> => {
const response = await apiClient.get(
`/system-accounts/${accountType}/transactions`,
{ params: { page, pageSize } }
);
return response.data.data;
},
};
// Helper to categorize accounts for display

View File

@ -1,5 +1,6 @@
'use client';
import Link from 'next/link';
import {
Table,
TableBody,
@ -11,16 +12,19 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { formatDecimal } from '@/lib/utils/format';
import { getAccountDisplayInfo, type SystemAccount } from '@/types/system-account';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { FileText } from 'lucide-react';
interface AccountsTableProps {
title: string;
accounts: SystemAccount[];
isLoading?: boolean;
showSyncInfo?: boolean;
showMiningData?: boolean;
}
export function AccountsTable({
@ -28,7 +32,16 @@ export function AccountsTable({
accounts,
isLoading = false,
showSyncInfo = false,
showMiningData = true,
}: AccountsTableProps) {
// 计算列数
const getColumnCount = () => {
let count = 5; // 基础列: 类型, 名称, 算力, 来源, 操作
if (showMiningData) count += 1; // 已挖积分股
if (showSyncInfo) count += 1; // 同步时间
return count;
};
return (
<Card>
<CardHeader>
@ -45,19 +58,22 @@ export function AccountsTable({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
{showMiningData && (
<TableHead className="text-right"></TableHead>
)}
<TableHead></TableHead>
{showSyncInfo && <TableHead></TableHead>}
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(3)].map((_, i) => (
<TableRow key={i}>
{[...Array(showSyncInfo ? 6 : 5)].map((_, j) => (
{[...Array(getColumnCount())].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
@ -67,7 +83,7 @@ export function AccountsTable({
) : accounts.length === 0 ? (
<TableRow>
<TableCell
colSpan={showSyncInfo ? 6 : 5}
colSpan={getColumnCount()}
className="text-center text-muted-foreground py-8"
>
@ -76,7 +92,8 @@ export function AccountsTable({
) : (
accounts.map((account) => {
const displayInfo = getAccountDisplayInfo(account.accountType);
const balance = account.contributionBalance || account.totalContribution || '0';
const contribution = account.contributionBalance || account.totalContribution || '0';
const totalMined = account.totalMined || '0';
return (
<TableRow key={account.accountType}>
@ -94,13 +111,20 @@ export function AccountsTable({
{account.name || displayInfo.label}
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(balance, 8)}
{formatDecimal(contribution, 8)}
{account.contributionNeverExpires && (
<Badge variant="outline" className="ml-2 text-xs">
</Badge>
)}
</TableCell>
{showMiningData && (
<TableCell className="text-right font-mono">
<span className={Number(totalMined) > 0 ? 'text-green-600' : 'text-muted-foreground'}>
{formatDecimal(totalMined, 8)}
</span>
</TableCell>
)}
<TableCell>
<Badge
variant={account.source === 'synced' ? 'default' : 'secondary'}
@ -119,8 +143,13 @@ export function AccountsTable({
: '-'}
</TableCell>
)}
<TableCell className="text-muted-foreground text-sm max-w-[200px] truncate">
{account.description || displayInfo.description}
<TableCell>
<Link href={`/system-accounts/${account.accountType}`}>
<Button variant="ghost" size="sm" className="h-8 px-2">
<FileText className="h-4 w-4 mr-1" />
</Button>
</Link>
</TableCell>
</TableRow>
);

View File

@ -46,3 +46,33 @@ export function useCategorizedAccounts() {
total: data?.total ?? 0,
};
}
/**
* Hook to fetch system account mining records
*/
export function useSystemAccountMiningRecords(
accountType: string,
page: number = 1,
pageSize: number = 20
) {
return useQuery({
queryKey: ['system-accounts', accountType, 'records', page, pageSize],
queryFn: () => systemAccountsApi.getMiningRecords(accountType, page, pageSize),
enabled: !!accountType,
});
}
/**
* Hook to fetch system account transactions
*/
export function useSystemAccountTransactions(
accountType: string,
page: number = 1,
pageSize: number = 20
) {
return useQuery({
queryKey: ['system-accounts', accountType, 'transactions', page, pageSize],
queryFn: () => systemAccountsApi.getTransactions(accountType, page, pageSize),
enabled: !!accountType,
});
}

View File

@ -1,11 +1,20 @@
// API
export { systemAccountsApi, categorizeAccounts } from './api/system-accounts.api';
export {
systemAccountsApi,
categorizeAccounts,
type SystemMiningRecord,
type SystemMiningRecordsResponse,
type SystemTransaction,
type SystemTransactionsResponse,
} from './api/system-accounts.api';
// Hooks
export {
useSystemAccounts,
useSystemAccountsSummary,
useCategorizedAccounts,
useSystemAccountMiningRecords,
useSystemAccountTransactions,
} from './hooks/use-system-accounts';
// Components

View File

@ -21,6 +21,11 @@ export interface SystemAccount {
totalContribution?: string;
contributionBalance?: string;
contributionNeverExpires?: boolean;
// 挖矿数据
totalMined?: string;
availableBalance?: string;
miningContribution?: string;
miningLastSyncedAt?: string;
syncedAt?: string;
createdAt?: string;
source: AccountSource;