feat: 统一USDC金额格式化,添加formatUSDC工具函数

- 新增 formatUSDC 函数:最多显示4位小数,自动去除尾随零(截断,不四舍五入)
- 统一导出所有工具函数到 utils/index.ts
- 替换所有页面中的USDC显示为 formatUSDC 函数
  - TemplateList.tsx
  - CopyTradingAdd.tsx
  - PositionList.tsx
  - AccountList.tsx
  - AccountDetail.tsx
  - OrderList.tsx
  - Statistics.tsx
- 更新前端开发规范,添加USDC格式化规范说明
- 统一工具函数导入方式(从 ../utils 导入)
This commit is contained in:
WrBug
2025-12-01 23:22:18 +08:00
parent fd10a31780
commit 4d322424c1
10 changed files with 156 additions and 40 deletions
+74
View File
@@ -176,6 +176,80 @@ export const useMarketStore = create<MarketStore>((set) => ({
}));
```
### USDC 金额格式化规范
- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示
- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC
- **禁止**硬编码小数位数格式化 USDC
- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数
#### formatUSDC 函数说明
- **位置**: `src/utils/index.ts`
- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
- **参数**: `value: string | number | undefined | null`
- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'`
#### 使用示例
```typescript
// ✅ 正确:使用 formatUSDC 格式化 USDC 金额
import { formatUSDC } from '../utils'
const BalanceDisplay: React.FC<{ balance: string }> = ({ balance }) => {
return <span>{formatUSDC(balance)} USDC</span>
}
// ✅ 正确:在表格列中使用
const columns = [
{
title: '余额',
dataIndex: 'balance',
render: (balance: string) => `${formatUSDC(balance)} USDC`
}
]
// ✅ 正确:在统计组件中使用
<Statistic
title="总盈亏"
value={formatUSDC(stats?.totalPnl || '0')}
suffix="USDC"
/>
// ❌ 错误:直接使用 toFixed
const balance = parseFloat(value).toFixed(4) // 禁止
// ❌ 错误:硬编码格式化
const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止
// ❌ 错误:使用自定义格式化函数
const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止
```
#### 格式化规则
- **最多显示 4 位小数**:如果金额超过 4 位小数,截断到 4 位(不四舍五入)
- **自动去除尾随零**:去除不必要的尾随零和小数点
- **示例**
- `formatUSDC(1.23)` => `"1.23"`
- `formatUSDC(1.23456)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(1.2)` => `"1.2"`
- `formatUSDC(1)` => `"1"`
- `formatUSDC(1.23459)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(null)` => `"-"`
- `formatUSDC(undefined)` => `"-"`
#### 工具函数统一导出
- 所有工具函数统一从 `src/utils/index.ts` 导出
- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入
- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出
```typescript
// ✅ 正确:从 utils 统一导入
import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils'
// ❌ 错误:从具体文件导入
import { formatUSDC } from '../utils/index'
import { getAddressFromPrivateKey } from '../utils/ethers'
```
## 移动端适配示例
### 响应式布局
+3 -2
View File
@@ -5,6 +5,7 @@ import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/ico
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Title } = Typography
@@ -212,7 +213,7 @@ const AccountDetail: React.FC = () => {
<Spin size="small" />
) : balance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{balance} USDC
{formatUSDC(balance)} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
@@ -307,7 +308,7 @@ const AccountDetail: React.FC = () => {
fontWeight: 'bold',
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{account.totalPnl} USDC
{formatUSDC(account.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
} from '../utils/ethers'
} from '../utils'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
+8 -7
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined, EditOutlined }
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Title } = Typography
@@ -261,7 +262,7 @@ const AccountList: React.FC = () => {
}
const balanceObj = balanceMap[record.id]
const balance = balanceObj?.total || record.balance || '-'
return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-'
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-'
}
},
{
@@ -359,7 +360,7 @@ const AccountList: React.FC = () => {
: {balanceLoading[record.id] ? (
<Spin size="small" style={{ marginLeft: '4px' }} />
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
`${balanceMap[record.id].total} USDC`
`${formatUSDC(balanceMap[record.id].total)} USDC`
) : (
'-'
)}
@@ -370,7 +371,7 @@ const AccountList: React.FC = () => {
color: '#666',
marginTop: '4px'
}}>
: {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC
: {formatUSDC(balanceMap[record.id].available)} USDC | : {formatUSDC(balanceMap[record.id].position)} USDC
</div>
)}
{(record.activeOrders !== undefined && record.activeOrders !== null) && (
@@ -597,7 +598,7 @@ const AccountList: React.FC = () => {
<Spin size="small" />
) : detailBalance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
{detailBalance.total} USDC
{formatUSDC(detailBalance.total)} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
@@ -608,7 +609,7 @@ const AccountList: React.FC = () => {
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#52c41a' }}>
{detailBalance.available} USDC
{formatUSDC(detailBalance.available)} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
@@ -619,7 +620,7 @@ const AccountList: React.FC = () => {
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#1890ff' }}>
{detailBalance.position} USDC
{formatUSDC(detailBalance.position)} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
@@ -696,7 +697,7 @@ const AccountList: React.FC = () => {
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{detailAccount.totalPnl} USDC
{formatUSDC(detailAccount.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
+2 -1
View File
@@ -6,6 +6,7 @@ import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { Account, Leader, CopyTradingTemplate } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Title } = Typography
const { Option } = Select
@@ -114,7 +115,7 @@ const CopyTradingAdd: React.FC = () => {
<Select placeholder="请选择模板">
{templates.map(template => (
<Option key={template.id} value={template.id}>
{template.templateName} ({template.copyMode === 'RATIO' ? `比例 ${template.copyRatio}x` : `固定 ${template.fixedAmount ? parseFloat(template.fixedAmount).toFixed(4) : '0.0000'} USDC`})
{template.templateName} ({template.copyMode === 'RATIO' ? `比例 ${template.copyRatio}x` : `固定 ${template.fixedAmount ? formatUSDC(template.fixedAmount) : '0.0000'} USDC`})
</Option>
))}
</Select>
+2 -1
View File
@@ -3,6 +3,7 @@ import { Card, Table, Tag, message } from 'antd'
import { apiService } from '../services/api'
import type { CopyOrder } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const OrderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -115,7 +116,7 @@ const OrderList: React.FC = () => {
key: 'pnl',
render: (pnl: string | undefined) => pnl ? (
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
{pnl} USDC
{formatUSDC(pnl)} USDC
</span>
) : '-'
},
+17 -16
View File
@@ -7,6 +7,7 @@ import { getPositionKey } from '../types'
import { useMediaQuery } from 'react-responsive'
import { useWebSocketSubscription } from '../hooks/useWebSocket'
import { wsManager } from '../services/websocket'
import { formatUSDC } from '../utils'
type PositionFilter = 'current' | 'historical'
type ViewMode = 'card' | 'list'
@@ -611,7 +612,7 @@ const PositionList: React.FC = () => {
fontWeight: '500',
color: isProfit ? '#52c41a' : '#f5222d'
}}>
{pnlNum >= 0 ? '+' : ''}{formatNumber(position.pnl, 2)} USDC
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC
</span>
</div>
)}
@@ -634,7 +635,7 @@ const PositionList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}></span>
<span style={{ fontSize: '13px', fontWeight: '500' }}>
{formatNumber(position.initialValue, 2)} USDC
{formatUSDC(position.initialValue)} USDC
</span>
</div>
{positionFilter === 'current' && position.currentPrice && (
@@ -648,7 +649,7 @@ const PositionList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}></span>
<span style={{ fontSize: '13px', fontWeight: '600' }}>
{formatNumber(position.currentValue, 2)} USDC
{formatUSDC(position.currentValue)} USDC
</span>
</div>
</>
@@ -691,7 +692,7 @@ const PositionList: React.FC = () => {
fontWeight: 'bold',
color: isProfit ? '#52c41a' : '#f5222d'
}}>
{pnlNum >= 0 ? '+' : ''}{formatNumber(position.pnl, 2)} USDC
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -718,7 +719,7 @@ const PositionList: React.FC = () => {
color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d',
fontWeight: '500'
}}>
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatNumber(position.realizedPnl, 2)} USDC
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatUSDC(position.realizedPnl)} USDC
</span>
</div>
)}
@@ -872,7 +873,7 @@ const PositionList: React.FC = () => {
key: 'initialValue',
render: (value: string) => (
<span>
{formatNumber(value, 2)} USDC
{formatUSDC(value)} USDC
</span>
),
align: 'right' as const,
@@ -897,7 +898,7 @@ const PositionList: React.FC = () => {
key: 'currentValue',
render: (value: string) => (
<span style={{ fontWeight: 'bold' }}>
{formatNumber(value, 2)} USDC
{formatUSDC(value)} USDC
</span>
),
align: 'right' as const,
@@ -928,7 +929,7 @@ const PositionList: React.FC = () => {
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
fontWeight: 'bold'
}}>
{pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC
{pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC
</div>
<div style={{
fontSize: '12px',
@@ -961,7 +962,7 @@ const PositionList: React.FC = () => {
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
fontWeight: 'bold'
}}>
{pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC
{pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC
</div>
{record.percentRealizedPnl && (
<div style={{
@@ -1191,7 +1192,7 @@ const PositionList: React.FC = () => {
borderColor: '#52c41a'
}}
>
({redeemableSummary.totalCount}, {formatNumber(redeemableSummary.totalValue, 2)} USDC)
({redeemableSummary.totalCount}, {formatUSDC(redeemableSummary.totalValue)} USDC)
</Button>
)}
</div>
@@ -1214,14 +1215,14 @@ const PositionList: React.FC = () => {
<span>
{' '}
<span style={{ fontWeight: 600 }}>
{formatNumber(positionTotals.totalInitialValue.toString(), 2)} USDC
{formatUSDC(positionTotals.totalInitialValue.toString())} USDC
</span>
</span>
<span>
{' '}
<span style={{ fontWeight: 600 }}>
{positionFilter === 'current'
? `${formatNumber(positionTotals.totalCurrentValue.toString(), 2)} USDC`
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
: '-'}
</span>
</span>
@@ -1234,7 +1235,7 @@ const PositionList: React.FC = () => {
}}
>
{positionTotals.totalPnl >= 0 ? '+' : ''}
{formatNumber(positionTotals.totalPnl.toString(), 2)} USDC
{formatUSDC(positionTotals.totalPnl.toString())} USDC
</span>
</span>
<span>
@@ -1246,7 +1247,7 @@ const PositionList: React.FC = () => {
}}
>
{positionTotals.totalRealizedPnl >= 0 ? '+' : ''}
{formatNumber(positionTotals.totalRealizedPnl.toString(), 2)} USDC
{formatUSDC(positionTotals.totalRealizedPnl.toString())} USDC
</span>
</span>
</div>
@@ -1460,7 +1461,7 @@ const PositionList: React.FC = () => {
color: currentPnl.pnl >= 0 ? '#52c41a' : '#f5222d',
marginBottom: '4px'
}}>
{currentPnl.pnl >= 0 ? '+' : ''}{currentPnl.pnl.toFixed(2)} USDC
{currentPnl.pnl >= 0 ? '+' : ''}{formatUSDC(currentPnl.pnl)} USDC
</div>
<div style={{
fontSize: '14px',
@@ -1500,7 +1501,7 @@ const PositionList: React.FC = () => {
</Descriptions.Item>
<Descriptions.Item label="可赎回总价值">
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#52c41a' }}>
{formatNumber(redeemableSummary.totalValue, 2)} USDC
{formatUSDC(redeemableSummary.totalValue)} USDC
</span>
</Descriptions.Item>
<Descriptions.Item label="涉及账户">
+5 -8
View File
@@ -3,6 +3,7 @@ import { Card, Row, Col, Statistic, message } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { formatUSDC } from '../utils'
const Statistics: React.FC = () => {
const [stats, setStats] = useState<StatisticsType | null>(null)
@@ -48,8 +49,7 @@ const Statistics: React.FC = () => {
<Card>
<Statistic
title="总盈亏"
value={stats?.totalPnl || '0'}
precision={2}
value={formatUSDC(stats?.totalPnl || '0')}
prefix={stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
suffix="USDC"
@@ -72,8 +72,7 @@ const Statistics: React.FC = () => {
<Card>
<Statistic
title="平均盈亏"
value={stats?.avgPnl || '0'}
precision={2}
value={formatUSDC(stats?.avgPnl || '0')}
suffix="USDC"
loading={loading}
/>
@@ -83,8 +82,7 @@ const Statistics: React.FC = () => {
<Card>
<Statistic
title="最大盈利"
value={stats?.maxProfit || '0'}
precision={2}
value={formatUSDC(stats?.maxProfit || '0')}
prefix={<ArrowUpOutlined />}
valueStyle={{ color: '#3f8600' }}
suffix="USDC"
@@ -96,8 +94,7 @@ const Statistics: React.FC = () => {
<Card>
<Statistic
title="最大亏损"
value={stats?.maxLoss || '0'}
precision={2}
value={formatUSDC(stats?.maxLoss || '0')}
prefix={<ArrowDownOutlined />}
valueStyle={{ color: '#cf1322' }}
suffix="USDC"
+5 -4
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-d
import { apiService } from '../services/api'
import type { CopyTradingTemplate } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Search } = Input
@@ -163,7 +164,7 @@ const TemplateList: React.FC = () => {
if (record.copyMode === 'RATIO') {
return `比例 ${record.copyRatio}x`
} else if (record.copyMode === 'FIXED' && record.fixedAmount) {
return `固定 ${parseFloat(record.fixedAmount).toFixed(4)} USDC`
return `固定 ${formatUSDC(record.fixedAmount)} USDC`
}
return '-'
}
@@ -331,7 +332,7 @@ const TemplateList: React.FC = () => {
{template.copyMode === 'RATIO'
? `比例 ${template.copyRatio}x`
: template.fixedAmount
? `固定 ${parseFloat(template.fixedAmount).toFixed(4)} USDC`
? `固定 ${formatUSDC(template.fixedAmount)} USDC`
: '-'
}
</div>
@@ -343,11 +344,11 @@ const TemplateList: React.FC = () => {
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '13px', color: '#333' }}>
{template.maxOrderSize && (
<span>: {parseFloat(template.maxOrderSize).toFixed(4)} USDC</span>
<span>: {formatUSDC(template.maxOrderSize)} USDC</span>
)}
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
{template.minOrderSize && (
<span>: {parseFloat(template.minOrderSize).toFixed(4)} USDC</span>
<span>: {formatUSDC(template.minOrderSize)} USDC</span>
)}
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#999' }}></span>}
</div>
+39
View File
@@ -0,0 +1,39 @@
/**
* 格式化 USDC 金额
* 最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
* @param value - 金额值(字符串或数字)
* @returns 格式化后的字符串,如果值为空或无效则返回 '-'
* @example
* formatUSDC(1.23) => "1.23"
* formatUSDC(1.23456) => "1.2345"
* formatUSDC(1.2) => "1.2"
* formatUSDC(1) => "1"
*/
export const formatUSDC = (value: string | number | undefined | null): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) {
return '-'
}
// 使用 Math.floor 截断到4位小数(不四舍五入)
const multiplier = Math.pow(10, 4)
const truncated = Math.floor(num * multiplier) / multiplier
// 使用 toFixed(4) 确保格式一致,然后去除尾随零和小数点
return truncated.toFixed(4).replace(/\.?0+$/, '')
}
// 统一导出 ethers 相关工具函数
export {
getAddressFromPrivateKey,
getAddressFromMnemonic,
getPrivateKeyFromMnemonic,
isValidMnemonic,
isValidWalletAddress,
isValidPrivateKey
} from './ethers'