Merge branch 'origin/beta' into nofxos/test

# Conflicts:
#	config/database_pg.go
This commit is contained in:
icy
2025-11-07 16:37:21 +08:00
28 changed files with 3434 additions and 193 deletions

View File

@@ -13,6 +13,7 @@ import { useAuth } from '../contexts/AuthContext'
import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { TwoStageKeyModal } from './TwoStageKeyModal'
import {
Bot,
Brain,
@@ -46,6 +47,12 @@ function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
function maskSecret(value: string): string {
if (!value) return ''
const length = Math.min(value.length, 16)
return '•'.repeat(length)
}
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
}
@@ -143,30 +150,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
allExchanges?.filter((e) => {
if (!e.enabled) return false
// Aster 交易所需要特殊字段
if (e.id === 'aster') {
return (
e.asterUser &&
e.asterUser.trim() !== '' &&
e.asterSigner &&
e.asterSigner.trim() !== '' &&
e.asterPrivateKey &&
e.asterPrivateKey.trim() !== ''
)
}
// Hyperliquid 只需要私钥作为apiKey钱包地址会自动从私钥生成
if (e.id === 'hyperliquid') {
return e.apiKey && e.apiKey.trim() !== ''
}
// Binance 等其他交易所需要 apiKey 和 secretKey
return (
e.apiKey &&
e.apiKey.trim() !== '' &&
e.secretKey &&
e.secretKey.trim() !== ''
)
// 由于API不再返回敏感字段信息只能基于enabled状态判断
// 实际的配置验证将在后端进行
return true
}) || []
// 检查模型是否正在被运行中的交易员使用
@@ -445,7 +431,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
},
}
await api.updateExchangeConfigs(request)
await api.updateExchangeConfigsEncrypted(request)
const refreshed = await api.getExchangeConfigs()
setAllExchanges(refreshed)
@@ -494,7 +480,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
},
}
await api.updateExchangeConfigs(request)
await api.updateExchangeConfigsEncrypted(request)
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
@@ -811,7 +797,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
<div
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled && exchange.apiKey ? 'bg-green-400' : 'bg-gray-500'}`}
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
/>
</div>
)
@@ -1666,6 +1652,9 @@ function ExchangeConfigModal({
const [asterUser, setAsterUser] = useState('')
const [asterSigner, setAsterSigner] = useState('')
const [asterPrivateKey, setAsterPrivateKey] = useState('')
const [secureInputTarget, setSecureInputTarget] = useState<
null | 'hyperliquid' | 'aster'
>(null)
// 获取当前选择的交易所信息
// 编辑模式:从 configuredExchanges 查找(包含用户配置的 apiKey、secretKey 等)
@@ -1674,24 +1663,50 @@ function ExchangeConfigModal({
? configuredExchanges?.find(e => e.id === selectedExchangeId)
: supportedExchanges?.find(e => e.id === selectedExchangeId);
// 如果是编辑现有交易所,初始化表单数据
const secureInputContextLabel =
secureInputTarget === 'aster'
? t('asterExchangeName', language)
: secureInputTarget === 'hyperliquid'
? t('hyperliquidExchangeName', language)
: undefined
// 如果是编辑现有交易所,清空所有敏感字段以保证安全
useEffect(() => {
if (editingExchangeId && selectedExchange) {
setApiKey(selectedExchange.apiKey || '')
setSecretKey(selectedExchange.secretKey || '')
setPassphrase('') // Don't load existing passphrase for security
// 编辑模式下清空所有敏感字段,用户需要重新输入
setApiKey('')
setSecretKey('')
setPassphrase('')
setTestnet(selectedExchange.testnet || false)
// Hyperliquid 字段
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
// Aster 字段
setAsterUser(selectedExchange.asterUser || '')
setAsterSigner(selectedExchange.asterSigner || '')
setAsterPrivateKey('') // Don't load existing private key for security
setAsterSigner('')
setAsterPrivateKey('')
}
}, [editingExchangeId, selectedExchange])
const handleSecureInputComplete = ({
value,
obfuscationLog,
}: {
value: string
obfuscationLog: string[]
}) => {
const trimmed = value.trim()
if (secureInputTarget === 'hyperliquid') {
setApiKey(trimmed)
}
if (secureInputTarget === 'aster') {
setAsterPrivateKey(trimmed)
}
console.log('Secure input obfuscation log:', obfuscationLog)
setSecureInputTarget(null)
}
const handleSecureInputCancel = () => {
setSecureInputTarget(null)
}
// 加载服务器IP当选择binance时
useEffect(() => {
if (selectedExchangeId === 'binance' && !serverIP) {
@@ -1755,11 +1770,12 @@ function ExchangeConfigModal({
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative"
style={{ background: '#1E2329' }}
>
<>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative"
style={{ background: '#1E2329' }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{editingExchangeId
@@ -2094,19 +2110,55 @@ function ExchangeConfigModal({
>
{t('privateKey', language)}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={maskSecret(apiKey)}
readOnly
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<button
type="button"
onClick={() => setSecureInputTarget('hyperliquid')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
whiteSpace: 'nowrap',
}}
>
{apiKey
? t('secureInputReenter', language)
: t('secureInputButton', language)}
</button>
{apiKey && (
<button
type="button"
onClick={() => setApiKey('')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#1B1F2B',
color: '#848E9C',
whiteSpace: 'nowrap',
}}
>
{t('secureInputClear', language)}
</button>
)}
</div>
{apiKey && (
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('secureInputHint', language)}
</div>
)}
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('hyperliquidPrivateKeyDesc', language)}
</div>
@@ -2209,19 +2261,55 @@ function ExchangeConfigModal({
/>
</Tooltip>
</label>
<input
type="password"
value={asterPrivateKey}
onChange={(e) => setAsterPrivateKey(e.target.value)}
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={maskSecret(asterPrivateKey)}
readOnly
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<button
type="button"
onClick={() => setSecureInputTarget('aster')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
whiteSpace: 'nowrap',
}}
>
{asterPrivateKey
? t('secureInputReenter', language)
: t('secureInputButton', language)}
</button>
{asterPrivateKey && (
<button
type="button"
onClick={() => setAsterPrivateKey('')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#1B1F2B',
color: '#848E9C',
whiteSpace: 'nowrap',
}}
>
{t('secureInputClear', language)}
</button>
)}
</div>
{asterPrivateKey && (
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('secureInputHint', language)}
</div>
)}
</div>
</div>
</>
)}
@@ -2349,6 +2437,16 @@ function ExchangeConfigModal({
</div>
</div>
)}
</div>
</div>
<TwoStageKeyModal
isOpen={secureInputTarget !== null}
language={language}
contextLabel={secureInputContextLabel}
expectedLength={64}
onCancel={handleSecureInputCancel}
onComplete={handleSecureInputComplete}
/>
</>
)
}

View File

@@ -0,0 +1,320 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { t, type Language } from '../i18n/translations'
const DEFAULT_LENGTH = 64
function generateObfuscation(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function validatePrivateKeyFormat(value: string, expectedLength: number): boolean {
const normalized = value.startsWith('0x') ? value.slice(2) : value
if (normalized.length !== expectedLength) {
return false
}
return /^[0-9a-fA-F]+$/.test(normalized)
}
export interface TwoStageKeyModalResult {
value: string
obfuscationLog: string[]
}
interface TwoStageKeyModalProps {
isOpen: boolean
language: Language
onCancel: () => void
onComplete: (result: TwoStageKeyModalResult) => void
expectedLength?: number
contextLabel?: string
}
export function TwoStageKeyModal({
isOpen,
language,
onCancel,
onComplete,
expectedLength = DEFAULT_LENGTH,
contextLabel,
}: TwoStageKeyModalProps) {
const [stage, setStage] = useState<1 | 2>(1)
const [part1, setPart1] = useState('')
const [part2, setPart2] = useState('')
const [error, setError] = useState<string | null>(null)
const [clipboardStatus, setClipboardStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
const [obfuscationLog, setObfuscationLog] = useState<string[]>([])
const [processing, setProcessing] = useState(false)
const [manualObfuscationValue, setManualObfuscationValue] = useState<string | null>(null)
const stage1InputRef = useRef<HTMLInputElement | null>(null)
const stage2InputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, onCancel])
useEffect(() => {
if (!isOpen) {
setStage(1)
setPart1('')
setPart2('')
setError(null)
setClipboardStatus('idle')
setObfuscationLog([])
setProcessing(false)
setManualObfuscationValue(null)
return
}
const focusTimer = setTimeout(() => {
if (stage === 1) {
stage1InputRef.current?.focus()
} else {
stage2InputRef.current?.focus()
}
}, 10)
return () => clearTimeout(focusTimer)
}, [isOpen, stage])
const heading = useMemo(() => {
if (!contextLabel) {
return t('twoStageModalTitle', language)
}
return `${t('twoStageModalTitle', language)} · ${contextLabel}`
}, [contextLabel, language])
if (!isOpen) {
return null
}
const handleOverlayClick = () => {
if (!processing) {
onCancel()
}
}
const handleStage1Next = async () => {
if (!part1.trim()) {
setError(t('twoStageStage1Error', language))
return
}
setProcessing(true)
const obfuscation = generateObfuscation()
let copied = false
try {
await navigator.clipboard.writeText(obfuscation)
copied = true
setClipboardStatus('copied')
setManualObfuscationValue(null)
} catch (err) {
console.warn('Clipboard write failed', err)
setClipboardStatus('failed')
setManualObfuscationValue(obfuscation)
}
setObfuscationLog((prev) => [...prev, `stage1:${new Date().toISOString()}`])
setProcessing(false)
setError(null)
setStage(2)
if (copied) {
setManualObfuscationValue(null)
}
}
const handleSubmit = () => {
const cleanedPart1 = part1.trim()
const cleanedPart2 = part2.trim()
const combined = (cleanedPart1 + cleanedPart2).replace(/\s+/g, '')
if (!validatePrivateKeyFormat(combined, expectedLength)) {
setError(t('twoStageInvalidFormat', language, { length: expectedLength }))
return
}
setObfuscationLog((prev) => [...prev, `stage2:${new Date().toISOString()}`])
const result: TwoStageKeyModalResult = {
value: combined,
obfuscationLog: [...obfuscationLog, `stage2:${new Date().toISOString()}`],
}
onComplete(result)
}
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
onClick={handleOverlayClick}
>
<div
className="w-full max-w-md rounded-xl border border-[#2B3139] bg-[#0B0E11] p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-4">
<h2 className="text-lg font-semibold" style={{ color: '#EAECEF' }}>
{heading}
</h2>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('twoStageModalDescription', language, { length: expectedLength })}
</p>
</div>
{stage === 1 ? (
<div className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('twoStageStage1Title', language)}
</label>
<input
ref={stage1InputRef}
type="password"
value={part1}
onChange={(event) => setPart1(event.target.value)}
placeholder={t('twoStageStage1Placeholder', language)}
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
disabled={processing}
/>
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
{t('twoStageStage1Hint', language)}
</p>
</div>
{clipboardStatus === 'failed' && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
<div>{t('twoStageClipboardManual', language)}</div>
{manualObfuscationValue && (
<code className="mt-2 block select-all rounded bg-black/40 px-2 py-1 text-[11px] text-[#F0B90B]">
{manualObfuscationValue}
</code>
)}
</div>
)}
{error && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#1B1F2B', color: '#848E9C' }}
disabled={processing}
>
{t('twoStageCancel', language)}
</button>
<button
type="button"
onClick={handleStage1Next}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{
background: processing ? '#3d2e0d' : '#F0B90B',
color: processing ? '#a18a43' : '#000',
opacity: part1.trim().length === 0 ? 0.7 : 1,
}}
disabled={processing || part1.trim().length === 0}
>
{processing ? t('twoStageProcessing', language) : t('twoStageNext', language)}
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('twoStageStage2Title', language)}
</label>
<input
ref={stage2InputRef}
type="password"
value={part2}
onChange={(event) => setPart2(event.target.value)}
placeholder={t('twoStageStage2Placeholder', language)}
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
/>
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
{t('twoStageStage2Hint', language)}
</p>
</div>
{clipboardStatus === 'copied' && (
<div
className="rounded border border-[#F0B90B]/40 bg-[#F0B90B]/10 px-3 py-2 text-xs"
style={{ color: '#F0B90B' }}
>
{t('twoStageClipboardSuccess', language)}
</div>
)}
{clipboardStatus === 'failed' && manualObfuscationValue && (
<div
className="rounded border border-[#2B3139] bg-[#141821] px-3 py-2 text-xs"
style={{ color: '#EAECEF' }}
>
{t('twoStageClipboardReminder', language)}
</div>
)}
{error && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setStage(1)
setPart2('')
setError(null)
setClipboardStatus('idle')
}}
className="rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#1B1F2B', color: '#848E9C' }}
>
{t('twoStageBack', language)}
</button>
<button
type="button"
onClick={handleSubmit}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('twoStageSubmit', language)}
</button>
</div>
</div>
)}
</div>
</div>
)
return createPortal(modalContent, document.body)
}