feat: implement hybrid database architecture and frontend encryption

- Add PostgreSQL + SQLite hybrid database support with automatic switching
- Implement frontend AES-GCM + RSA-OAEP encryption for sensitive data
- Add comprehensive DatabaseInterface with all required methods
- Fix compilation issues with interface consistency
- Update all database method signatures to use DatabaseInterface
- Add missing UpdateTraderInitialBalance method to PostgreSQL implementation
- Integrate RSA public key distribution via /api/config endpoint
- Add frontend crypto service with proper error handling
- Support graceful degradation between encrypted and plaintext transmission
- Add directory creation for RSA keys and PEM parsing fixes
- Test both SQLite and PostgreSQL modes successfully
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
icy
2025-11-06 01:50:06 +08:00
parent ba0bb8c365
commit 7d58f56e49
104 changed files with 16864 additions and 4152 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from 'react'
import {
LineChart,
Line,
@@ -8,28 +8,35 @@ import {
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import useSWR from 'swr';
import { api } from '../lib/api';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown } from 'lucide-react'
} from 'recharts'
import useSWR from 'swr'
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import {
AlertTriangle,
BarChart3,
DollarSign,
Percent,
TrendingUp as ArrowUp,
TrendingDown as ArrowDown,
} from 'lucide-react'
interface EquityPoint {
timestamp: string;
total_equity: number;
pnl: number;
pnl_pct: number;
cycle_number: number;
timestamp: string
total_equity: number
pnl: number
pnl_pct: number
cycle_number: number
}
interface EquityChartProps {
traderId?: string;
traderId?: string
}
export function EquityChart({ traderId }: EquityChartProps) {
const { language } = useLanguage();
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar');
const { language } = useLanguage()
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR<EquityPoint[]>(
traderId ? `equity-history-${traderId}` : 'equity-history',
@@ -39,7 +46,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 20000,
}
);
)
const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account',
@@ -49,24 +56,24 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
)
if (error) {
return (
<div className='binance-card p-6'>
<div className="binance-card p-6">
<div
className='flex items-center gap-3 p-4 rounded'
className="flex items-center gap-3 p-4 rounded"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.2)',
}}
>
<AlertTriangle className='w-6 h-6' style={{ color: '#F6465D' }} />
<AlertTriangle className="w-6 h-6" style={{ color: '#F6465D' }} />
<div>
<div className='font-semibold' style={{ color: '#F6465D' }}>
<div className="font-semibold" style={{ color: '#F6465D' }}>
{t('loadingError', language)}
</div>
<div className='text-sm' style={{ color: '#848E9C' }}>
<div className="text-sm" style={{ color: '#848E9C' }}>
{error.message}
</div>
</div>
@@ -76,22 +83,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
}
// 过滤掉无效数据total_equity为0或小于1的数据点API失败导致
const validHistory = history?.filter(point => point.total_equity > 1) || [];
const validHistory = history?.filter((point) => point.total_equity > 1) || []
if (!validHistory || validHistory.length === 0) {
return (
<div className='binance-card p-6'>
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}>
<div className="binance-card p-6">
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
{t('accountEquityCurve', language)}
</h3>
<div className='text-center py-16' style={{ color: '#848E9C' }}>
<div className='mb-4 flex justify-center opacity-50'>
<BarChart3 className='w-16 h-16' />
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="mb-4 flex justify-center opacity-50">
<BarChart3 className="w-16 h-16" />
</div>
<div className='text-lg font-semibold mb-2'>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className='text-sm'>{t('dataWillAppear', language)}</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
</div>
)
@@ -99,20 +106,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
// 限制显示最近的数据点(性能优化)
// 如果数据超过2000个点只显示最近2000个
const MAX_DISPLAY_POINTS = 2000;
const displayHistory = validHistory.length > MAX_DISPLAY_POINTS
? validHistory.slice(-MAX_DISPLAY_POINTS)
: validHistory;
const MAX_DISPLAY_POINTS = 2000
const displayHistory =
validHistory.length > MAX_DISPLAY_POINTS
? validHistory.slice(-MAX_DISPLAY_POINTS)
: validHistory
// 计算初始余额(使用第一个有效数据点,如果无数据则从account获取,最后才用默认值
const initialBalance = validHistory[0]?.total_equity
|| account?.total_equity
|| 100; // 默认值改为100与常见配置一致
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推
const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
|| (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) // 备选:淨值 - 盈亏
|| 1000; // 默认值(与创建交易员时的默认配置一致
// 转换数据格式
const chartData = displayHistory.map((point) => {
const pnl = point.total_equity - initialBalance;
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2);
const pnl = point.total_equity - initialBalance
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
return {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -123,43 +131,45 @@ export function EquityChart({ traderId }: EquityChartProps) {
raw_equity: point.total_equity,
raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct),
};
});
}
})
const currentValue = chartData[chartData.length - 1];
const isProfit = currentValue.raw_pnl >= 0;
const currentValue = chartData[chartData.length - 1]
const isProfit = currentValue.raw_pnl >= 0
// 计算Y轴范围
const calculateYDomain = () => {
if (displayMode === 'percent') {
// 百分比模式找到最大最小值留20%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)];
const values = chartData.map((d) => d.value)
const minVal = Math.min(...values)
const maxVal = Math.max(...values)
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
} else {
// 美元模式以初始余额为基准上下留10%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values, initialBalance);
const maxVal = Math.max(...values, initialBalance);
const range = maxVal - minVal;
const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量
return [
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
const values = chartData.map((d) => d.value)
const minVal = Math.min(...values, initialBalance)
const maxVal = Math.max(...values, initialBalance)
const range = maxVal - minVal
const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
}
};
}
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const data = payload[0].payload
return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</div>
<div
className="rounded p-3 shadow-xl"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
Cycle #{data.cycle}
</div>
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT
</div>
@@ -172,38 +182,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%)
</div>
</div>
);
)
}
return null;
};
return null
}
return (
<div className='binance-card p-3 sm:p-5 animate-fade-in'>
<div className="binance-card p-3 sm:p-5 animate-fade-in">
{/* Header */}
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'>
<div className='flex-1'>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex-1">
<h3
className='text-base sm:text-lg font-bold mb-2'
className="text-base sm:text-lg font-bold mb-2"
style={{ color: '#EAECEF' }}
>
{t('accountEquityCurve', language)}
</h3>
<div className='flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4'>
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
<span
className='text-2xl sm:text-3xl font-bold mono'
className="text-2xl sm:text-3xl font-bold mono"
style={{ color: '#EAECEF' }}
>
{account?.total_equity.toFixed(2) || '0.00'}
<span
className='text-base sm:text-lg ml-1'
className="text-base sm:text-lg ml-1"
style={{ color: '#848E9C' }}
>
USDT
</span>
</span>
<div className='flex items-center gap-2 flex-wrap'>
<div className="flex items-center gap-2 flex-wrap">
<span
className='text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1'
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
style={{
color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit
@@ -216,12 +226,16 @@ export function EquityChart({ traderId }: EquityChartProps) {
}`,
}}
>
{isProfit ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
{isProfit ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
</span>
<span
className='text-xs sm:text-sm mono'
className="text-xs sm:text-sm mono"
style={{ color: '#848E9C' }}
>
({isProfit ? '+' : ''}
@@ -233,12 +247,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Display Mode Toggle */}
<div
className='flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto'
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<button
onClick={() => setDisplayMode('dollar')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'dollar'
? {
@@ -249,11 +263,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<DollarSign className='w-4 h-4' /> USDT
<DollarSign className="w-4 h-4" /> USDT
</button>
<button
onClick={() => setDisplayMode('percent')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'percent'
? {
@@ -264,15 +278,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<Percent className='w-4 h-4' />
<Percent className="w-4 h-4" />
</button>
</div>
</div>
{/* Chart */}
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<div
className="my-2"
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
<div
style={{
position: 'absolute',
top: '15px',
@@ -282,35 +303,35 @@ export function EquityChart({ traderId }: EquityChartProps) {
color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width='100%' height={280}>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={chartData}
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
>
<defs>
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} />
<stop offset='95%' stopColor='#FCD535' stopOpacity={0.2} />
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' />
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey='time'
stroke='#5E6673'
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor='end'
textAnchor="end"
height={60}
/>
<YAxis
stroke='#5E6673'
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
@@ -321,8 +342,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke='#474D57'
strokeDasharray='3 3'
stroke="#474D57"
strokeDasharray="3 3"
label={{
value:
displayMode === 'dollar'
@@ -333,9 +354,9 @@ export function EquityChart({ traderId }: EquityChartProps) {
}}
/>
<Line
type='natural'
dataKey='value'
stroke='url(#colorGradient)'
type="natural"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{
@@ -352,72 +373,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Footer Stats */}
<div
className='mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3'
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
style={{ borderTop: '1px solid #2B3139' }}
>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('initialBalance', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('currentEquity', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{currentValue.raw_equity.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('historicalCycles', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length} {t('cycles', language)}
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length > MAX_DISPLAY_POINTS