Merge branch 'dev' into quote-tweet-link

This commit is contained in:
Ember
2025-11-02 23:56:46 +08:00
committed by GitHub
131 changed files with 23476 additions and 2164 deletions

View File

@@ -1,5 +1,7 @@
# Dependencies
node_modules/
yarn.lock
pnpm-lock.yaml
# Build output (will be regenerated)
dist/

View File

@@ -2,11 +2,22 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-TM429527');</script>
<!-- End Google Tag Manager -->
<link rel="icon" type="image/svg+xml" href="/icons/nofx.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NOFX - AI Auto Trading Dashboard</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TM429527"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

117
web/package-lock.json generated
View File

@@ -8,12 +8,17 @@
"name": "nofx-web",
"version": "1.0.0",
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.552.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.2",
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -833,6 +838,39 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1503,6 +1541,18 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1904,6 +1954,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2156,6 +2233,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.552.0",
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.552.0.tgz",
"integrity": "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2202,6 +2288,21 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2960,6 +3061,16 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
@@ -3086,6 +3197,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -8,22 +8,27 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.552.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2",
"swr": "^2.2.5",
"recharts": "^2.15.2",
"date-fns": "^4.1.0",
"clsx": "^2.1.1"
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.3",
"vite": "^6.0.7",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20"
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^6.0.7"
}
}

View File

@@ -0,0 +1,23 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" width="40" viewBox="-52.785 -88 457.47 528"><path d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z" fill="#f0b90b"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="144" height="144" viewBox="0 0 144 144" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z" fill="#97FCE4"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

296
web/public/icons/nofx.svg Normal file
View File

@@ -0,0 +1,296 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100%" viewBox="0 0 1024 1024" enable-background="new 0 0 1024 1024" xml:space="preserve">
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
d="
M500.000000,1025.000000
C333.333344,1025.000000 167.166672,1025.000000 1.000000,1025.000000
C1.000000,683.666687 1.000000,342.333344 1.000000,1.000000
C342.333344,1.000000 683.666687,1.000000 1025.000000,1.000000
C1025.000000,342.333344 1025.000000,683.666687 1025.000000,1025.000000
C850.166687,1025.000000 675.333313,1025.000000 500.000000,1025.000000
M534.031067,584.483826
C534.031067,588.398560 534.031067,592.313354 534.031067,596.807495
C545.055725,596.807495 555.191895,596.895569 565.324890,596.757568
C568.675903,596.711914 570.108704,597.732422 570.067505,601.303345
C569.908142,615.130981 569.858154,628.962708 570.028137,642.789673
C570.077393,646.796997 568.523926,648.072266 564.742004,647.996033
C556.247864,647.824707 547.744873,648.036621 539.253845,647.796204
C535.514526,647.690369 533.817627,648.561218 533.996155,652.742493
C534.308533,660.059753 533.817566,667.412292 534.157166,674.727051
C534.375000,679.418884 533.066345,681.195984 528.085999,681.018494
C517.601807,680.644897 507.094879,680.960999 496.599945,680.810852
C493.243347,680.762817 491.914062,681.787109 491.941986,685.357300
C492.094421,704.850281 492.122192,724.345581 491.971497,743.838440
C491.941101,747.773010 493.228638,749.004578 497.124115,748.978394
C518.949890,748.831421 540.777771,748.821838 562.603271,748.980225
C566.173035,749.006165 567.215881,747.891907 567.153503,744.444214
C566.966858,734.117310 567.222534,723.781128 566.953308,713.457703
C566.847107,709.385132 568.577148,708.202698 572.203125,708.286438
C578.188965,708.424683 584.192078,708.207458 590.160583,708.579163
C594.476196,708.847839 596.032410,707.578918 595.856140,703.035217
C595.503296,693.939453 595.751465,684.820312 595.751465,675.649231
C596.925232,675.398560 597.552307,675.149048 598.180054,675.147583
C615.339722,675.106445 632.499634,675.126099 649.658997,675.034668
C652.399414,675.020081 653.028992,676.300964 653.009949,678.716370
C652.946899,686.712646 653.113281,694.711670 652.973572,702.705811
C652.917664,705.902710 653.994019,707.188904 657.276245,707.107605
C663.269775,706.959106 669.278931,707.315979 675.266113,707.076050
C679.086365,706.922974 680.126648,708.399414 680.068604,712.025635
C679.897949,722.685486 680.140930,733.351624 679.989075,744.012024
C679.937683,747.618469 681.041504,748.997803 684.821899,748.976501
C708.314026,748.844055 731.807556,748.852051 755.299805,748.975403
C758.820862,748.993835 759.868530,747.806763 759.844788,744.357910
C759.709351,724.698547 759.709839,705.037598 759.818420,685.377991
C759.837585,681.910950 758.672913,680.734314 755.189087,680.794067
C744.195374,680.982727 733.194397,680.763184 722.201355,680.970520
C718.237671,681.045227 716.403687,679.967041 716.487549,675.664490
C716.636658,668.012939 716.185547,660.350403 716.298767,652.697144
C716.354187,648.952393 714.940552,647.784058 711.308655,647.875549
C703.149780,648.080994 694.974915,647.740051 686.821289,648.032654
C682.197937,648.198547 680.731934,646.551575 680.854858,641.973938
C681.136780,631.484619 680.943665,620.982361 680.937012,610.485352
C680.928345,596.707458 680.925720,596.708313 694.772095,596.712830
C701.716309,596.715027 708.660583,596.713257 716.156738,596.713257
C716.156738,587.047791 716.274170,578.086670 716.100098,569.131104
C716.028503,565.448486 717.396729,564.077209 721.057495,564.125061
C731.718384,564.264526 742.382324,564.204712 753.044861,564.170166
C755.140137,564.163330 757.234619,563.919800 759.374390,563.782654
C759.374390,541.682739 759.374390,520.113953 759.374390,498.512848
C758.314819,498.283966 757.679260,498.027313 757.043457,498.026611
C732.552490,497.999329 708.061401,498.037842 683.570801,497.939880
C680.143433,497.926178 679.874512,499.756226 679.904175,502.387390
C680.015015,512.215210 679.874512,522.048523 680.153259,531.870544
C680.266418,535.860596 679.224426,537.680786 674.914673,537.479248
C669.263062,537.215027 663.581482,537.623352 657.925659,537.406860
C654.311951,537.268616 652.858887,538.322510 652.991699,542.182861
C653.266357,550.170227 652.971191,558.175842 653.164490,566.167786
C653.254150,569.872559 651.856873,571.069702 648.200317,571.033142
C632.374023,570.874939 616.544556,570.857117 600.718201,571.000671
C597.009949,571.034241 595.702759,569.735596 595.771912,566.091858
C595.923645,558.098816 595.724426,550.099670 595.818298,542.104797
C595.854309,539.035889 594.871948,537.527527 591.554504,537.577759
C585.402100,537.671082 579.222473,537.141418 573.097961,537.541626
C567.969849,537.876709 566.857544,535.717102 566.994751,531.152100
C567.259827,522.329468 567.088135,513.493835 567.107239,504.663605
C567.121216,498.211700 567.128967,498.312378 560.831177,498.362671
C539.515869,498.532898 518.197937,498.814789 496.885956,498.590637
C492.527435,498.544800 491.869690,499.960571 491.895660,503.604095
C492.027496,522.097412 492.121552,540.592773 491.953308,559.084961
C491.917023,563.073425 493.143402,564.285767 497.052917,564.207214
C507.545197,563.996338 518.045471,564.219666 528.540771,564.103271
C532.401672,564.060364 534.411804,565.121643 534.089355,569.498840
C533.747498,574.138367 534.024414,578.823547 534.031067,584.483826
M253.353867,389.499359
C253.353867,425.600555 253.353867,461.701721 253.353867,498.020203
C273.984375,498.020203 294.234894,498.020203 314.879120,498.020203
C314.879120,466.733032 314.879120,435.525452 314.879120,404.231567
C325.188171,404.231567 335.102448,404.231567 345.353882,404.231567
C345.353882,416.582062 345.353882,428.655182 345.353882,441.171600
C356.752899,441.171600 367.816437,441.171600 379.144989,441.171600
C379.144989,452.733887 379.144989,463.845337 379.144989,475.463928
C387.484772,475.463928 395.556122,475.463928 404.001831,475.463928
C404.001831,483.396393 404.001831,490.822571 404.001831,498.353760
C424.266266,498.353760 444.136444,498.353760 464.049652,498.353760
C464.049652,428.578674 464.049652,359.158661 464.049652,289.508362
C443.934937,289.508362 424.057556,289.508362 403.805725,289.508362
C403.805725,326.036926 403.805725,362.284485 403.805725,398.714264
C393.694244,398.714264 383.958435,398.714264 373.754669,398.714264
C373.754669,387.079407 373.754669,375.679657 373.754669,363.763580
C364.226013,363.763580 355.157928,363.763580 345.754578,363.763580
C345.754578,350.975952 345.754578,338.719330 345.754578,326.013428
C335.334686,326.013428 325.264954,326.013428 314.727661,326.013428
C314.727661,313.817993 314.727661,302.078217 314.727661,290.294006
C294.024200,290.294006 273.800537,290.294006 253.353867,290.294006
C253.353867,323.258392 253.353867,355.878815 253.353867,389.499359
M253.324738,540.524414
C253.324738,609.787170 253.324738,679.049988 253.324738,748.416748
C273.913696,748.416748 293.998413,748.416748 314.709351,748.416748
C314.709351,719.517822 314.709351,690.832581 314.709351,661.901611
C350.772034,661.901611 386.208771,661.901611 421.853760,661.901611
C421.853760,645.744873 421.853760,629.843628 421.853760,613.658813
C386.514313,613.658813 351.406067,613.658813 315.981873,613.658813
C315.981873,602.016785 315.981873,590.659973 315.981873,578.921387
C365.640533,578.921387 414.883820,578.921387 464.020874,578.921387
C464.020874,562.126465 464.020874,545.717834 464.020874,529.276367
C393.615936,529.276367 323.565338,529.276367 253.319580,529.276367
C253.319580,532.918762 253.319580,536.226440 253.324738,540.524414
M492.003387,396.500000
C492.003387,418.075806 492.003387,439.651642 492.003387,461.703796
C504.388947,461.703796 516.107178,461.703796 528.313721,461.703796
C528.313721,472.416870 528.313721,482.654999 528.313721,492.669708
C565.543945,492.669708 602.271057,492.669708 639.460571,492.669708
C639.460571,481.874329 639.460571,471.464661 639.460571,460.745056
C652.522522,460.745056 665.122131,460.745056 677.734863,460.745056
C677.734863,427.692261 677.734863,394.973724 677.734863,361.929138
C664.783020,361.929138 652.186462,361.929138 639.150879,361.929138
C639.150879,350.688141 639.150879,339.765076 639.150879,328.555176
C601.986938,328.555176 565.268433,328.555176 527.973755,328.555176
C527.973755,339.860962 527.973755,350.904510 527.973755,362.241943
C515.652466,362.241943 503.905975,362.241943 492.002960,362.241943
C492.002960,373.544617 492.002960,384.522308 492.003387,396.500000
M680.952881,342.679718
C699.195862,342.679718 717.438782,342.679718 735.967041,342.679718
C735.967041,325.691925 736.031189,309.222900 735.806641,292.757812
C735.793213,291.775330 733.476685,289.998322 732.215942,289.983917
C715.887634,289.797485 699.555664,289.801270 683.227722,290.003845
C682.113281,290.017670 680.069885,291.995911 680.064697,293.068268
C679.986694,309.383881 680.176758,325.700775 680.952881,342.679718
z"/>
<path fill="#F9AF07" opacity="1.000000" stroke="none"
d="
M534.031128,583.986328
C534.024414,578.823547 533.747498,574.138367 534.089355,569.498840
C534.411804,565.121643 532.401672,564.060364 528.540771,564.103271
C518.045471,564.219666 507.545197,563.996338 497.052917,564.207214
C493.143402,564.285767 491.917023,563.073425 491.953308,559.084961
C492.121552,540.592773 492.027496,522.097412 491.895660,503.604095
C491.869690,499.960571 492.527435,498.544800 496.885956,498.590637
C518.197937,498.814789 539.515869,498.532898 560.831177,498.362671
C567.128967,498.312378 567.121216,498.211700 567.107239,504.663605
C567.088135,513.493835 567.259827,522.329468 566.994751,531.152100
C566.857544,535.717102 567.969849,537.876709 573.097961,537.541626
C579.222473,537.141418 585.402100,537.671082 591.554504,537.577759
C594.871948,537.527527 595.854309,539.035889 595.818298,542.104797
C595.724426,550.099670 595.923645,558.098816 595.771912,566.091858
C595.702759,569.735596 597.009949,571.034241 600.718201,571.000671
C616.544556,570.857117 632.374023,570.874939 648.200317,571.033142
C651.856873,571.069702 653.254150,569.872559 653.164490,566.167786
C652.971191,558.175842 653.266357,550.170227 652.991699,542.182861
C652.858887,538.322510 654.311951,537.268616 657.925659,537.406860
C663.581482,537.623352 669.263062,537.215027 674.914673,537.479248
C679.224426,537.680786 680.266418,535.860596 680.153259,531.870544
C679.874512,522.048523 680.015015,512.215210 679.904175,502.387390
C679.874512,499.756226 680.143433,497.926178 683.570801,497.939880
C708.061401,498.037842 732.552490,497.999329 757.043457,498.026611
C757.679260,498.027313 758.314819,498.283966 759.374390,498.512848
C759.374390,520.113953 759.374390,541.682739 759.374390,563.782654
C757.234619,563.919800 755.140137,564.163330 753.044861,564.170166
C742.382324,564.204712 731.718384,564.264526 721.057495,564.125061
C717.396729,564.077209 716.028503,565.448486 716.100098,569.131104
C716.274170,578.086670 716.156738,587.047791 716.156738,596.713257
C708.660583,596.713257 701.716309,596.715027 694.772095,596.712830
C680.925720,596.708313 680.928345,596.707458 680.937012,610.485352
C680.943665,620.982361 681.136780,631.484619 680.854858,641.973938
C680.731934,646.551575 682.197937,648.198547 686.821289,648.032654
C694.974915,647.740051 703.149780,648.080994 711.308655,647.875549
C714.940552,647.784058 716.354187,648.952393 716.298767,652.697144
C716.185547,660.350403 716.636658,668.012939 716.487549,675.664490
C716.403687,679.967041 718.237671,681.045227 722.201355,680.970520
C733.194397,680.763184 744.195374,680.982727 755.189087,680.794067
C758.672913,680.734314 759.837585,681.910950 759.818420,685.377991
C759.709839,705.037598 759.709351,724.698547 759.844788,744.357910
C759.868530,747.806763 758.820862,748.993835 755.299805,748.975403
C731.807556,748.852051 708.314026,748.844055 684.821899,748.976501
C681.041504,748.997803 679.937683,747.618469 679.989075,744.012024
C680.140930,733.351624 679.897949,722.685486 680.068604,712.025635
C680.126648,708.399414 679.086365,706.922974 675.266113,707.076050
C669.278931,707.315979 663.269775,706.959106 657.276245,707.107605
C653.994019,707.188904 652.917664,705.902710 652.973572,702.705811
C653.113281,694.711670 652.946899,686.712646 653.009949,678.716370
C653.028992,676.300964 652.399414,675.020081 649.658997,675.034668
C632.499634,675.126099 615.339722,675.106445 598.180054,675.147583
C597.552307,675.149048 596.925232,675.398560 595.751465,675.649231
C595.751465,684.820312 595.503296,693.939453 595.856140,703.035217
C596.032410,707.578918 594.476196,708.847839 590.160583,708.579163
C584.192078,708.207458 578.188965,708.424683 572.203125,708.286438
C568.577148,708.202698 566.847107,709.385132 566.953308,713.457703
C567.222534,723.781128 566.966858,734.117310 567.153503,744.444214
C567.215881,747.891907 566.173035,749.006165 562.603271,748.980225
C540.777771,748.821838 518.949890,748.831421 497.124115,748.978394
C493.228638,749.004578 491.941101,747.773010 491.971497,743.838440
C492.122192,724.345581 492.094421,704.850281 491.941986,685.357300
C491.914062,681.787109 493.243347,680.762817 496.599945,680.810852
C507.094879,680.960999 517.601807,680.644897 528.085999,681.018494
C533.066345,681.195984 534.375000,679.418884 534.157166,674.727051
C533.817566,667.412292 534.308533,660.059753 533.996155,652.742493
C533.817627,648.561218 535.514526,647.690369 539.253845,647.796204
C547.744873,648.036621 556.247864,647.824707 564.742004,647.996033
C568.523926,648.072266 570.077393,646.796997 570.028137,642.789673
C569.858154,628.962708 569.908142,615.130981 570.067505,601.303345
C570.108704,597.732422 568.675903,596.711914 565.324890,596.757568
C555.191895,596.895569 545.055725,596.807495 534.031067,596.807495
C534.031067,592.313354 534.031067,588.398560 534.031128,583.986328
z"/>
<path fill="#020202" opacity="1.000000" stroke="none"
d="
M253.353867,388.999268
C253.353867,355.878815 253.353867,323.258392 253.353867,290.294006
C273.800537,290.294006 294.024200,290.294006 314.727661,290.294006
C314.727661,302.078217 314.727661,313.817993 314.727661,326.013428
C325.264954,326.013428 335.334686,326.013428 345.754578,326.013428
C345.754578,338.719330 345.754578,350.975952 345.754578,363.763580
C355.157928,363.763580 364.226013,363.763580 373.754669,363.763580
C373.754669,375.679657 373.754669,387.079407 373.754669,398.714264
C383.958435,398.714264 393.694244,398.714264 403.805725,398.714264
C403.805725,362.284485 403.805725,326.036926 403.805725,289.508362
C424.057556,289.508362 443.934937,289.508362 464.049652,289.508362
C464.049652,359.158661 464.049652,428.578674 464.049652,498.353760
C444.136444,498.353760 424.266266,498.353760 404.001831,498.353760
C404.001831,490.822571 404.001831,483.396393 404.001831,475.463928
C395.556122,475.463928 387.484772,475.463928 379.144989,475.463928
C379.144989,463.845337 379.144989,452.733887 379.144989,441.171600
C367.816437,441.171600 356.752899,441.171600 345.353882,441.171600
C345.353882,428.655182 345.353882,416.582062 345.353882,404.231567
C335.102448,404.231567 325.188171,404.231567 314.879120,404.231567
C314.879120,435.525452 314.879120,466.733032 314.879120,498.020203
C294.234894,498.020203 273.984375,498.020203 253.353867,498.020203
C253.353867,461.701721 253.353867,425.600555 253.353867,388.999268
z"/>
<path fill="#020202" opacity="1.000000" stroke="none"
d="
M253.322159,540.029297
C253.319580,536.226440 253.319580,532.918762 253.319580,529.276367
C323.565338,529.276367 393.615936,529.276367 464.020874,529.276367
C464.020874,545.717834 464.020874,562.126465 464.020874,578.921387
C414.883820,578.921387 365.640533,578.921387 315.981873,578.921387
C315.981873,590.659973 315.981873,602.016785 315.981873,613.658813
C351.406067,613.658813 386.514313,613.658813 421.853760,613.658813
C421.853760,629.843628 421.853760,645.744873 421.853760,661.901611
C386.208771,661.901611 350.772034,661.901611 314.709351,661.901611
C314.709351,690.832581 314.709351,719.517822 314.709351,748.416748
C293.998413,748.416748 273.913696,748.416748 253.324738,748.416748
C253.324738,679.049988 253.324738,609.787170 253.322159,540.029297
z"/>
<path fill="#020202" opacity="1.000000" stroke="none"
d="
M492.003174,396.000000
C492.002960,384.522308 492.002960,373.544617 492.002960,362.241943
C503.905975,362.241943 515.652466,362.241943 527.973755,362.241943
C527.973755,350.904510 527.973755,339.860962 527.973755,328.555176
C565.268433,328.555176 601.986938,328.555176 639.150879,328.555176
C639.150879,339.765076 639.150879,350.688141 639.150879,361.929138
C652.186462,361.929138 664.783020,361.929138 677.734863,361.929138
C677.734863,394.973724 677.734863,427.692261 677.734863,460.745056
C665.122131,460.745056 652.522522,460.745056 639.460571,460.745056
C639.460571,471.464661 639.460571,481.874329 639.460571,492.669708
C602.271057,492.669708 565.543945,492.669708 528.313721,492.669708
C528.313721,482.654999 528.313721,472.416870 528.313721,461.703796
C516.107178,461.703796 504.388947,461.703796 492.003387,461.703796
C492.003387,439.651642 492.003387,418.075806 492.003174,396.000000
M614.295959,428.499878
C614.295959,410.563873 614.295959,392.627899 614.295959,374.437195
C594.356567,374.437195 574.817383,374.437195 555.216553,374.437195
C555.216553,399.301392 555.216553,423.880280 555.216553,448.470581
C574.879578,448.470581 594.303711,448.470581 614.296204,448.470581
C614.296204,442.071533 614.296204,435.785706 614.295959,428.499878
z"/>
<path fill="#F7AF0C" opacity="1.000000" stroke="none"
d="
M680.628540,342.348541
C680.176758,325.700775 679.986694,309.383881 680.064697,293.068268
C680.069885,291.995911 682.113281,290.017670 683.227722,290.003845
C699.555664,289.801270 715.887634,289.797485 732.215942,289.983917
C733.476685,289.998322 735.793213,291.775330 735.806641,292.757812
C736.031189,309.222900 735.967041,325.691925 735.967041,342.679718
C717.438782,342.679718 699.195862,342.679718 680.628540,342.348541
z"/>
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
d="
M614.296082,428.999878
C614.296204,435.785706 614.296204,442.071533 614.296204,448.470581
C594.303711,448.470581 574.879578,448.470581 555.216553,448.470581
C555.216553,423.880280 555.216553,399.301392 555.216553,374.437195
C574.817383,374.437195 594.356567,374.437195 614.295959,374.437195
C614.295959,392.627899 614.295959,410.563873 614.296082,428.999878
z"/>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
web/public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
web/public/images/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

View File

@@ -2,10 +2,17 @@ import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { api } from './lib/api';
import { EquityChart } from './components/EquityChart';
import { AITradersPage } from './components/AITradersPage';
import { LoginPage } from './components/LoginPage';
import { RegisterPage } from './components/RegisterPage';
import { CompetitionPage } from './components/CompetitionPage';
import { LandingPage } from './pages/LandingPage';
import AILearning from './components/AILearning';
import { LanguageProvider, useLanguage } from './contexts/LanguageContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { t, type Language } from './i18n/translations';
import { useSystemConfig } from './hooks/useSystemConfig';
import { Zap } from 'lucide-react';
import type {
SystemStatus,
AccountInfo,
@@ -15,10 +22,27 @@ import type {
TraderInfo,
} from './types';
type Page = 'competition' | 'trader';
type Page = 'competition' | 'traders' | 'trader';
// 获取友好的AI模型名称
function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek';
case 'qwen':
return 'Qwen';
case 'claude':
return 'Claude';
default:
return modelId.toUpperCase();
}
}
function App() {
const { language, setLanguage } = useLanguage();
const { user, token, logout, isLoading } = useAuth();
const { config: systemConfig, loading: configLoading } = useSystemConfig();
const [route, setRoute] = useState(window.location.pathname);
// 从URL hash读取初始页面状态支持刷新保持页面
const getInitialPage = (): Page => {
@@ -45,11 +69,11 @@ function App() {
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// 切换页面时更新URL hash
const navigateToPage = (page: Page) => {
setCurrentPage(page);
window.location.hash = page === 'competition' ? '' : 'trader';
};
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage这个函数暂时保留用于未来扩展)
// const navigateToPage = (page: Page) => {
// setCurrentPage(page);
// window.location.hash = page === 'competition' ? '' : 'trader';
// };
// 获取trader列表
const { data: traders } = useSWR<TraderInfo[]>('traders', api.getTraders, {
@@ -133,59 +157,120 @@ function App() {
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId);
// Handle routing
useEffect(() => {
const handlePopState = () => {
setRoute(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
<div className="text-center">
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 mx-auto mb-4 animate-pulse" />
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
</div>
</div>
);
}
// Show landing page for root route when not authenticated
if (!systemConfig?.admin_mode && (!user || !token)) {
if (route === '/login') {
return <LoginPage />;
}
if (route === '/register') {
return <RegisterPage />;
}
// Default to landing page when not authenticated
return <LandingPage />;
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
{/* Header - Binance Style */}
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
<div className="max-w-[1920px] mx-auto px-3 sm:px-6 py-3 sm:py-4">
{/* Mobile: Two rows, Desktop: Single row */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: Logo and Title */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-lg sm:text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
<div className="max-w-[1920px] mx-auto px-6 py-4">
<div className="relative flex items-center">
{/* Left - Logo and Title */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center">
<img src="/icons/nofx.svg?v=2" alt="NOFX" className="w-8 h-8" />
</div>
<div>
<h1 className="text-base sm:text-xl font-bold leading-tight" style={{ color: '#EAECEF' }}>
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-xs mono hidden sm:block" style={{ color: '#848E9C' }}>
<p className="text-xs mono" style={{ color: '#848E9C' }}>
{t('subtitle', language)}
</p>
</div>
</div>
{/* Right: Controls - Wrap on mobile */}
<div className="flex items-center gap-2 flex-wrap md:flex-nowrap">
{/* GitHub Link - Hidden on mobile, icon only on tablet */}
<a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
className="hidden sm:flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139';
e.currentTarget.style.color = '#EAECEF';
e.currentTarget.style.borderColor = '#F0B90B';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329';
e.currentTarget.style.color = '#848E9C';
e.currentTarget.style.borderColor = '#2B3139';
}}
{/* Center - Page Toggle (absolutely positioned) */}
<div className="absolute left-1/2 transform -translate-x-1/2 flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setCurrentPage('competition')}
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'competition'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span className="hidden md:inline">GitHub</span>
</a>
{t('aiCompetition', language)}
</button>
<button
onClick={() => setCurrentPage('traders')}
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'traders'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('aiTraders', language)}
</button>
<button
onClick={() => setCurrentPage('trader')}
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'trader'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('tradingPanel', language)}
</button>
</div>
{/* Right - Actions */}
<div className="ml-auto flex items-center gap-3">
{/* User Info - Only show if not in admin mode */}
{!systemConfig?.admin_mode && user && (
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold" style={{ background: '#F0B90B', color: '#000' }}>
{user.email[0].toUpperCase()}
</div>
<span className="text-sm" style={{ color: '#EAECEF' }}>{user.email}</span>
</div>
)}
{/* Admin Mode Indicator */}
{systemConfig?.admin_mode && (
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-semibold" style={{ color: '#F0B90B' }}>{t('adminMode', language)}</span>
</div>
)}
{/* Language Toggle */}
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}>
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setLanguage('zh')}
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all"
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
@@ -195,7 +280,7 @@ function App() {
</button>
<button
onClick={() => setLanguage('en')}
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all"
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
@@ -205,63 +290,15 @@ function App() {
</button>
</div>
{/* Page Toggle */}
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}>
{/* Logout Button - Only show if not in admin mode */}
{!systemConfig?.admin_mode && (
<button
onClick={() => navigateToPage('competition')}
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
style={currentPage === 'competition'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
onClick={logout}
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
>
{t('competition', language)}
{t('logout', language)}
</button>
<button
onClick={() => navigateToPage('trader')}
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
style={currentPage === 'trader'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('details', language)}
</button>
</div>
{/* Trader Selector (only show on trader page) */}
{currentPage === 'trader' && traders && traders.length > 0 && (
<select
value={selectedTraderId}
onChange={(e) => setSelectedTraderId(e.target.value)}
className="rounded px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium cursor-pointer transition-colors flex-1 sm:flex-initial"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name} ({trader.ai_model.toUpperCase()})
</option>
))}
</select>
)}
{/* Status Indicator (only show on trader page) */}
{currentPage === 'trader' && status && (
<div
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded"
style={status.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', border: '1px solid rgba(14, 203, 129, 0.2)' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }
}
>
<div
className={`w-2 h-2 rounded-full ${status.is_running ? 'pulse-glow' : ''}`}
style={{ background: status.is_running ? '#0ECB81' : '#F6465D' }}
/>
<span className="font-semibold mono text-xs">
{t(status.is_running ? 'running' : 'stopped', language)}
</span>
</div>
)}
</div>
</div>
@@ -272,6 +309,13 @@ function App() {
<main className="max-w-[1920px] mx-auto px-6 py-6">
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId);
setCurrentPage('trader');
}}
/>
) : (
<TraderDetailsPage
selectedTrader={selectedTrader}
@@ -282,6 +326,9 @@ function App() {
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
selectedTraderId={selectedTraderId}
onTraderSelect={setSelectedTraderId}
/>
)}
</main>
@@ -291,12 +338,12 @@ function App() {
<div className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm" style={{ color: '#5E6673' }}>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-2">
<div className="mt-4">
<a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139';
@@ -312,7 +359,7 @@ function App() {
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span>Star on GitHub</span>
GitHub
</a>
</div>
</div>
@@ -330,8 +377,14 @@ function TraderDetailsPage({
decisions,
lastUpdate,
language,
traders,
selectedTraderId,
onTraderSelect,
}: {
selectedTrader?: TraderInfo;
traders?: TraderInfo[];
selectedTraderId?: string;
onTraderSelect: (traderId: string) => void;
status?: SystemStatus;
account?: AccountInfo;
positions?: Position[];
@@ -372,14 +425,35 @@ function TraderDetailsPage({
<div>
{/* Trader Header */}
<div className="mb-6 rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}>
<h2 className="text-2xl font-bold mb-3 flex items-center gap-2" style={{ color: '#EAECEF' }}>
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
🤖
</span>
{selectedTrader.trader_name}
</h2>
<div className="flex items-start justify-between mb-3">
<h2 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
🤖
</span>
{selectedTrader.trader_name}
</h2>
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: '#848E9C' }}>{t('switchTrader', language)}:</span>
<select
value={selectedTraderId}
onChange={(e) => onTraderSelect(e.target.value)}
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm" style={{ color: '#848E9C' }}>
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}>{selectedTrader.ai_model.toUpperCase()}</span></span>
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa' }}>{getModelDisplayName(selectedTrader.ai_model.split('_').pop() || selectedTrader.ai_model)}</span></span>
{status && (
<>
<span></span>
@@ -395,9 +469,9 @@ function TraderDetailsPage({
{account && (
<div className="mb-4 p-3 rounded text-xs font-mono" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div style={{ color: '#848E9C' }}>
🔄 Last Update: {lastUpdate} | Total Equity: {account.total_equity?.toFixed(2) || '0.00'} |
Available: {account.available_balance?.toFixed(2) || '0.00'} | P&L: {account.total_pnl?.toFixed(2) || '0.00'}{' '}
({account.total_pnl_pct?.toFixed(2) || '0.00'}%)
🔄 Last Update: {lastUpdate} | Total Equity: {account?.total_equity?.toFixed(2) || '0.00'} |
Available: {account?.available_balance?.toFixed(2) || '0.00'} | P&L: {account?.total_pnl?.toFixed(2) || '0.00'}{' '}
({account?.total_pnl_pct?.toFixed(2) || '0.00'}%)
</div>
</div>
)}
@@ -721,11 +795,13 @@ function DecisionCard({ decision, language }: { decision: DecisionRecord; langua
);
}
// Wrap App with LanguageProvider
export default function AppWithLanguage() {
// Wrap App with providers
export default function AppWithProviders() {
return (
<LanguageProvider>
<App />
<AuthProvider>
<App />
</AuthProvider>
</LanguageProvider>
);
}

View File

@@ -2,6 +2,7 @@ import useSWR from 'swr';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { api } from '../lib/api';
import { Brain, BarChart3, TrendingUp, TrendingDown, Sparkles, Coins, Trophy, ScrollText, Lightbulb } from 'lucide-react';
interface TradeOutcome {
symbol: string;
@@ -72,7 +73,9 @@ export default function AILearning({ traderId }: AILearningProps) {
if (!performance) {
return (
<div className="rounded p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div style={{ color: '#848E9C' }}>📊 {t('loading', language)}</div>
<div className="flex items-center gap-2" style={{ color: '#848E9C' }}>
<BarChart3 className="w-4 h-4" /> {t('loading', language)}
</div>
</div>
);
}
@@ -81,7 +84,7 @@ export default function AILearning({ traderId }: AILearningProps) {
return (
<div className="rounded p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🧠</span>
<Brain className="w-5 h-5" style={{ color: '#8B5CF6' }} />
<h2 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('aiLearning', language)}</h2>
</div>
<div style={{ color: '#848E9C' }}>
@@ -109,12 +112,12 @@ export default function AILearning({ traderId }: AILearningProps) {
filter: 'blur(60px)'
}} />
<div className="relative flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl" style={{
<div className="w-16 h-16 rounded-2xl flex items-center justify-center" style={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%)',
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.5)',
border: '2px solid rgba(255, 255, 255, 0.1)'
}}>
🧠
<Brain className="w-8 h-8" style={{ color: '#FFF' }} />
</div>
<div>
<h2 className="text-3xl font-bold mb-1" style={{
@@ -149,7 +152,9 @@ export default function AILearning({ traderId }: AILearningProps) {
<div className="text-4xl font-bold mono mb-1" style={{ color: '#E0E7FF' }}>
{performance.total_trades}
</div>
<div className="text-xs" style={{ color: '#6366F1' }}>📊 Trades</div>
<div className="text-xs flex items-center gap-1" style={{ color: '#6366F1' }}>
<BarChart3 className="w-3 h-3" /> Trades
</div>
</div>
</div>
@@ -199,7 +204,9 @@ export default function AILearning({ traderId }: AILearningProps) {
<div className="text-4xl font-bold mono mb-1" style={{ color: '#10B981' }}>
+{(performance.avg_win || 0).toFixed(2)}
</div>
<div className="text-xs" style={{ color: '#6EE7B7' }}>📈 USDT Average</div>
<div className="text-xs flex items-center gap-1" style={{ color: '#6EE7B7' }}>
<TrendingUp className="w-3 h-3" /> USDT Average
</div>
</div>
</div>
@@ -220,7 +227,9 @@ export default function AILearning({ traderId }: AILearningProps) {
<div className="text-4xl font-bold mono mb-1" style={{ color: '#F87171' }}>
{(performance.avg_loss || 0).toFixed(2)}
</div>
<div className="text-xs" style={{ color: '#FCA5A5' }}>📉 USDT Average</div>
<div className="text-xs flex items-center gap-1" style={{ color: '#FCA5A5' }}>
<TrendingDown className="w-3 h-3" /> USDT Average
</div>
</div>
</div>
</div>
@@ -239,11 +248,11 @@ export default function AILearning({ traderId }: AILearningProps) {
}} />
<div className="relative">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{
background: 'rgba(139, 92, 246, 0.3)',
border: '1px solid rgba(139, 92, 246, 0.5)'
}}>
🧬
<Sparkles className="w-6 h-6" style={{ color: '#A78BFA' }} />
</div>
<div>
<div className="text-lg font-bold" style={{ color: '#C4B5FD' }}></div>
@@ -307,11 +316,11 @@ export default function AILearning({ traderId }: AILearningProps) {
}} />
<div className="relative">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{
background: 'rgba(240, 185, 11, 0.3)',
border: '1px solid rgba(240, 185, 11, 0.5)'
}}>
💰
<Coins className="w-6 h-6" style={{ color: '#FCD34D' }} />
</div>
<div>
<div className="text-lg font-bold" style={{ color: '#FCD34D' }}>
@@ -373,7 +382,7 @@ export default function AILearning({ traderId }: AILearningProps) {
boxShadow: '0 4px 16px rgba(16, 185, 129, 0.1)'
}}>
<div className="flex items-center gap-2 mb-3">
<span className="text-2xl">🏆</span>
<Trophy className="w-6 h-6" style={{ color: '#10B981' }} />
<span className="text-sm font-semibold" style={{ color: '#6EE7B7' }}>{t('bestPerformer', language)}</span>
</div>
<div className="text-3xl font-bold mono mb-1" style={{ color: '#10B981' }}>
@@ -395,7 +404,7 @@ export default function AILearning({ traderId }: AILearningProps) {
boxShadow: '0 4px 16px rgba(248, 113, 113, 0.1)'
}}>
<div className="flex items-center gap-2 mb-3">
<span className="text-2xl">📉</span>
<TrendingDown className="w-6 h-6" style={{ color: '#F87171' }} />
<span className="text-sm font-semibold" style={{ color: '#FCA5A5' }}>{t('worstPerformer', language)}</span>
</div>
<div className="text-3xl font-bold mono mb-1" style={{ color: '#F87171' }}>
@@ -428,7 +437,7 @@ export default function AILearning({ traderId }: AILearningProps) {
backdropFilter: 'blur(10px)'
}}>
<h3 className="font-bold flex items-center gap-2 text-lg" style={{ color: '#E0E7FF' }}>
📊 {t('symbolPerformance', language)}
<BarChart3 className="w-5 h-5" /> {t('symbolPerformance', language)}
</h3>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 'calc(100vh - 280px)' }}>
@@ -488,7 +497,7 @@ export default function AILearning({ traderId }: AILearningProps) {
backdropFilter: 'blur(10px)'
}}>
<div className="flex items-center gap-2">
<span className="text-2xl">📜</span>
<ScrollText className="w-6 h-6" style={{ color: '#FCD34D' }} />
<div>
<h3 className="font-bold text-lg" style={{ color: '#FCD34D' }}>{t('tradeHistory', language)}</h3>
<p className="text-xs" style={{ color: '#94A3B8' }}>
@@ -631,7 +640,9 @@ export default function AILearning({ traderId }: AILearningProps) {
})
) : (
<div className="p-6 text-center">
<div className="text-4xl mb-2 opacity-50">📜</div>
<div className="mb-2 flex justify-center opacity-50">
<ScrollText className="w-10 h-10" style={{ color: '#94A3B8' }} />
</div>
<div style={{ color: '#94A3B8' }}>{t('noCompletedTrades', language)}</div>
</div>
)}
@@ -646,11 +657,11 @@ export default function AILearning({ traderId }: AILearningProps) {
boxShadow: '0 4px 16px rgba(240, 185, 11, 0.1)'
}}>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-xl flex-shrink-0" style={{
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{
background: 'rgba(240, 185, 11, 0.2)',
border: '1px solid rgba(240, 185, 11, 0.3)'
}}>
💡
<Lightbulb className="w-5 h-5" style={{ color: '#FCD34D' }} />
</div>
<div>
<h3 className="font-bold mb-3 text-base" style={{ color: '#FCD34D' }}>{t('howAILearns', language)}</h3>

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,16 @@ import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionTraderData } from '../types';
import { getTraderColor } from '../utils/traderColors';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { BarChart3 } from 'lucide-react';
interface ComparisonChartProps {
traders: CompetitionTraderData[];
}
export function ComparisonChart({ traders }: ComparisonChartProps) {
const { language } = useLanguage();
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
// 生成唯一的key当traders变化时会触发重新请求
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
@@ -116,12 +120,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
if (combined.length > 0) {
const lastPoint = combined[combined.length - 1];
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
console.log('Last 3 points:', combined.slice(-3).map(p => ({
time: p.time,
timestamp: p.timestamp,
deepseek: p.deepseek_trader_pnl_pct,
qwen: p.qwen_trader_pnl_pct
})));
}
return combined;
@@ -139,9 +137,9 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
if (combinedData.length === 0) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">线</div>
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
);
}
@@ -317,25 +315,25 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
{/* Stats */}
<div className="mt-6 grid grid-cols-4 gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</div>
<div className="text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{combinedData.length} </div>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: combinedData.length})}</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</div>
<div className="text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
{currentGap.toFixed(2)}%
</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{combinedData.length > MAX_DISPLAY_POINTS
? `最近 ${MAX_DISPLAY_POINTS}`
: '全部数据'}
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)}
</div>
</div>
</div>

View File

@@ -1,13 +1,19 @@
import { useState } from 'react';
import { Trophy, Medal } from 'lucide-react';
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionData } from '../types';
import { ComparisonChart } from './ComparisonChart';
import { TraderConfigViewModal } from './TraderConfigViewModal';
import { getTraderColor } from '../utils/traderColors';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { getTraderColor } from '../utils/traderColors';
export function CompetitionPage() {
const { language } = useLanguage();
const [selectedTrader, setSelectedTrader] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const { data: competition } = useSWR<CompetitionData>(
'competition',
api.getCompetition,
@@ -18,6 +24,21 @@ export function CompetitionPage() {
}
);
const handleTraderClick = async (traderId: string) => {
try {
const traderConfig = await api.getTraderConfig(traderId);
setSelectedTrader(traderConfig);
setIsModalOpen(true);
} catch (error) {
console.error('Failed to fetch trader config:', error);
}
};
const closeModal = () => {
setIsModalOpen(false);
setSelectedTrader(null);
};
if (!competition || !competition.traders) {
return (
<div className="space-y-6">
@@ -54,11 +75,8 @@ export function CompetitionPage() {
{/* Competition Header - 精简版 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
🏆
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.15)', border: '1px solid rgba(240,185,11,0.3)' }}>
<Trophy className="w-6 h-6" style={{ color: '#F0B90B' }} />
</div>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
@@ -114,7 +132,8 @@ export function CompetitionPage() {
return (
<div
key={trader.trader_id}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px]"
onClick={() => handleTraderClick(trader.trader_id)}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
style={{
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
@@ -124,13 +143,13 @@ export function CompetitionPage() {
<div className="flex items-center justify-between">
{/* Rank & Name */}
<div className="flex items-center gap-3">
<div className="text-2xl w-6">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
<div className="w-6 flex items-center justify-center">
<Medal className="w-5 h-5" style={{ color: index === 0 ? '#F0B90B' : index === 1 ? '#C0C0C0' : '#CD7F32' }} />
</div>
<div>
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
<div className="text-xs mono font-semibold" style={{ color: traderColor }}>
{trader.ai_model.toUpperCase()}
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
</div>
</div>
</div>
@@ -223,11 +242,14 @@ export function CompetitionPage() {
>
<div className="text-center">
<div
className="text-base font-bold mb-2"
className="text-base font-bold mb-1"
style={{ color: getTraderColor(sortedTraders, trader.trader_id) }}
>
{trader.trader_name}
</div>
<div className="text-xs mono mb-2" style={{ color: '#848E9C' }}>
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
</div>
<div className="text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
@@ -248,6 +270,13 @@ export function CompetitionPage() {
</div>
</div>
)}
{/* Trader Config View Modal */}
<TraderConfigViewModal
isOpen={isModalOpen}
onClose={closeModal}
traderData={selectedTrader}
/>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Check } from "lucide-react";
import { cn } from "../lib/utils";
interface CryptoFeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
features: string[];
className?: string;
delay?: number;
}
export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureCardProps>(
({ icon, title, description, features, className, delay = 0 }, ref) => {
const [isHovered, setIsHovered] = React.useState(false);
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
>
<div
className={cn(
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl",
"bg-gradient-to-br from-[#0C0E12] to-[#1E2329]",
"border-[#2B3139] hover:border-[#F0B90B]/50",
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]",
className
)}
>
{/* Animated glow border effect */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
animate={{
opacity: isHovered ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: "32px 32px",
}}
/>
</div>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className={cn(
"mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl",
"bg-gradient-to-br from-[#F0B90B]/20 to-[#F0B90B]/5",
"border border-[#F0B90B]/30"
)}
animate={{
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? "0 0 20px rgba(240, 185, 11, 0.4)"
: "0 0 0px rgba(240, 185, 11, 0)",
}}
transition={{ duration: 0.3 }}
>
<div className="text-[#F0B90B]">{icon}</div>
</motion.div>
{/* Title */}
<h3 className="text-2xl font-bold text-[#EAECEF] mb-3">{title}</h3>
{/* Description */}
<p className="text-[#848E9C] mb-6 flex-grow leading-relaxed">{description}</p>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div className="w-5 h-5 rounded-full bg-[#F0B90B]/20 flex items-center justify-center">
<Check className="w-3 h-3 text-[#F0B90B]" />
</div>
</div>
<span className="text-sm text-[#EAECEF]">{feature}</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
);
CryptoFeatureCard.displayName = "CryptoFeatureCard";

View File

@@ -13,6 +13,7 @@ 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;
@@ -52,16 +53,26 @@ export function EquityChart({ traderId }: EquityChartProps) {
if (error) {
return (
<div className="binance-card p-6">
<div 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)' }}>
<div className="text-2xl"></div>
<div className='binance-card p-6'>
<div
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' }} />
<div>
<div className="font-semibold" style={{ color: '#F6465D' }}>{t('loadingError', language)}</div>
<div className="text-sm" style={{ color: '#848E9C' }}>{error.message}</div>
<div className='font-semibold' style={{ color: '#F6465D' }}>
{t('loadingError', language)}
</div>
<div className='text-sm' style={{ color: '#848E9C' }}>
{error.message}
</div>
</div>
</div>
</div>
);
)
}
// 过滤掉无效数据total_equity为0或小于1的数据点API失败导致
@@ -69,15 +80,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
if (!validHistory || validHistory.length === 0) {
return (
<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="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
<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>
<div className='text-lg font-semibold mb-2'>
{t('noHistoricalData', language)}
</div>
<div className='text-sm'>{t('dataWillAppear', language)}</div>
</div>
</div>
);
)
}
// 限制显示最近的数据点(性能优化)
@@ -161,142 +178,238 @@ export function EquityChart({ traderId }: EquityChartProps) {
};
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">
<h3 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">
<span className="text-2xl sm:text-3xl font-bold mono" style={{ color: '#EAECEF' }}>
<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'
style={{ color: '#EAECEF' }}
>
{t('accountEquityCurve', language)}
</h3>
<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'
style={{ color: '#EAECEF' }}
>
{account?.total_equity.toFixed(2) || '0.00'}
<span className="text-base sm:text-lg ml-1" style={{ color: '#848E9C' }}>USDT</span>
</span>
<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"
className='text-base sm:text-lg ml-1'
style={{ color: '#848E9C' }}
>
USDT
</span>
</span>
<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'
style={{
color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)',
border: `1px solid ${isProfit ? 'rgba(14, 203, 129, 0.2)' : 'rgba(246, 70, 93, 0.2)'}`
background: isProfit
? 'rgba(14, 203, 129, 0.1)'
: 'rgba(246, 70, 93, 0.1)',
border: `1px solid ${
isProfit
? 'rgba(14, 203, 129, 0.2)'
: 'rgba(246, 70, 93, 0.2)'
}`,
}}
>
{isProfit ? '▲' : '▼'} {isProfit ? '+' : ''}
{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" style={{ color: '#848E9C' }}>
({isProfit ? '+' : ''}{currentValue.raw_pnl.toFixed(2)} USDT)
<span
className='text-xs sm:text-sm mono'
style={{ color: '#848E9C' }}
>
({isProfit ? '+' : ''}
{currentValue.raw_pnl.toFixed(2)} USDT)
</span>
</div>
</div>
</div>
{/* Display Mode Toggle */}
<div 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' }}>
<div
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"
style={displayMode === 'dollar'
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
: { background: 'transparent', color: '#848E9C' }
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'
? {
background: '#F0B90B',
color: '#000',
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
}
: { background: 'transparent', color: '#848E9C' }
}
>
💵 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"
style={displayMode === 'percent'
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
: { background: 'transparent', color: '#848E9C' }
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'
? {
background: '#F0B90B',
color: '#000',
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
}
: { background: 'transparent', color: '#848E9C' }
}
>
📊 %
<Percent className='w-4 h-4' />
</button>
</div>
</div>
{/* Chart */}
<div className="my-2" style={{ borderRadius: '8px', overflow: 'hidden' }}>
<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>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) =>
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke="#474D57"
strokeDasharray="3 3"
label={{
value: displayMode === 'dollar' ? t('initialBalance', language).split(' ')[0] : '0%',
fill: '#848E9C',
fontSize: 12,
}}
/>
<Line
type="natural"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{ r: 6, fill: '#FCD535', stroke: '#F0B90B', strokeWidth: 2 }}
connectNulls={true}
/>
</LineChart>
</ResponsiveContainer>
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden' }}>
<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>
</defs>
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' />
<XAxis
dataKey='time'
stroke='#5E6673'
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor='end'
height={60}
/>
<YAxis
stroke='#5E6673'
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) =>
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke='#474D57'
strokeDasharray='3 3'
label={{
value:
displayMode === 'dollar'
? t('initialBalance', language).split(' ')[0]
: '0%',
fill: '#848E9C',
fontSize: 12,
}}
/>
<Line
type='natural'
dataKey='value'
stroke='url(#colorGradient)'
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{
r: 6,
fill: '#FCD535',
stroke: '#F0B90B',
strokeWidth: 2,
}}
connectNulls={true}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Footer Stats */}
<div 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" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div 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" style={{ color: '#EAECEF' }}>
<div
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'
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
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'
style={{ color: '#EAECEF' }}
>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div 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" style={{ color: '#848E9C' }}>{t('currentEquity', language)}</div>
<div className="text-xs sm:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
<div
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'
style={{ color: '#848E9C' }}
>
{t('currentEquity', language)}
</div>
<div
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" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div 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" style={{ color: '#EAECEF' }}>{validHistory.length} {t('cycles', language)}</div>
<div
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'
style={{ color: '#848E9C' }}
>
{t('historicalCycles', language)}
</div>
<div
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" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div 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" style={{ color: '#EAECEF' }}>
<div
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'
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
style={{ color: '#EAECEF' }}
>
{validHistory.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)
}
: t('allData', language)}
</div>
</div>
</div>
</div>
);
)
}

View File

@@ -0,0 +1,120 @@
import React from 'react';
interface IconProps {
width?: number;
height?: number;
className?: string;
}
// Binance SVG 图标组件
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="-52.785 -88 457.47 528"
className={className}
>
<path
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
fill="#f0b90b"
/>
</svg>
);
// Hyperliquid SVG 图标组件
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 144 144"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
fill="#97FCE4"
/>
</svg>
);
// Aster SVG 图标组件
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
</linearGradient>
</defs>
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
</svg>
);
// 获取交易所图标的函数
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
// 支持完整ID或类型名
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' :
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' :
exchangeType.toLowerCase().includes('aster') ? 'aster' :
exchangeType.toLowerCase();
const iconProps = {
width: props.width || 24,
height: props.height || 24,
className: props.className
};
switch (type) {
case 'binance':
case 'cex':
return <BinanceIcon {...iconProps} />;
case 'hyperliquid':
case 'dex':
return <HyperliquidIcon {...iconProps} />;
case 'aster':
return <AsterIcon {...iconProps} />;
default:
return (
<div
className={props.className}
style={{
width: props.width || 24,
height: props.height || 24,
borderRadius: '50%',
background: '#2B3139',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
color: '#EAECEF'
}}
>
{type[0]?.toUpperCase() || '?'}
</div>
);
}
};

View File

@@ -0,0 +1,59 @@
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
interface HeaderProps {
simple?: boolean; // For login/register pages
}
export function Header({ simple = false }: HeaderProps) {
const { language, setLanguage } = useLanguage();
return (
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
<div className="max-w-[1920px] mx-auto px-6 py-4">
<div className="flex items-center justify-between">
{/* Left - Logo and Title */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center">
<img src="/images/logo.png" alt="NoFx Logo" className="w-8 h-8" />
</div>
<div>
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
{!simple && (
<p className="text-xs mono" style={{ color: '#848E9C' }}>
{t('subtitle', language)}
</p>
)}
</div>
</div>
{/* Right - Language Toggle (always show) */}
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setLanguage('zh')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
</button>
<button
onClick={() => setLanguage('en')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
EN
</button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { Header } from './Header';
import { ArrowLeft } from 'lucide-react';
export function LoginPage() {
const { language } = useLanguage();
const { login, verifyOTP } = useAuth();
const [step, setStep] = useState<'login' | 'otp'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(email, password);
if (result.success) {
if (result.requiresOTP && result.userID) {
setUserID(result.userID);
setStep('otp');
}
} else {
setError(result.message || t('loginFailed', language));
}
setLoading(false);
};
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await verifyOTP(userID, otpCode);
if (!result.success) {
setError(result.message || t('verificationFailed', language));
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
return (
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
<Header simple />
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div className="w-full max-w-md">
{/* Back to Home */}
<button
onClick={() => {
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
style={{ color: '#848E9C' }}
>
<ArrowLeft className="w-4 h-4" />
</button>
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 object-contain" />
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('loginTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'login' ? t('loginTitle', language) : t('enterOTPCode', language)}
</p>
</div>
{/* Login Form */}
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('loginButton', language)}
</button>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}<br />
{t('enterOTPCode', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('verifyOTP', language)}
</button>
</div>
</form>
)}
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('noAccount', language)}{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline"
style={{ color: '#F0B90B' }}
>
{t('registerNow', language)}
</button>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
interface IconProps {
width?: number;
height?: number;
className?: string;
}
// 获取AI模型图标的函数
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
// 支持完整ID或类型名
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType;
let iconPath: string | null = null;
switch (type) {
case 'deepseek':
iconPath = '/icons/deepseek.svg';
break;
case 'qwen':
iconPath = '/icons/qwen.svg';
break;
default:
return null;
}
return (
<img
src={iconPath}
alt={`${type} icon`}
width={props.width || 24}
height={props.height || 24}
className={props.className}
style={{ borderRadius: '50%' }}
/>
);
};

View File

@@ -0,0 +1,326 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { ArrowLeft } from 'lucide-react';
export function RegisterPage() {
const { language } = useLanguage();
const { register, completeRegistration } = useAuth();
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [otpSecret, setOtpSecret] = useState('');
const [qrCodeURL, setQrCodeURL] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError(t('passwordMismatch', language));
return;
}
if (password.length < 6) {
setError(t('passwordTooShort', language));
return;
}
setLoading(true);
const result = await register(email, password);
if (result.success && result.userID) {
setUserID(result.userID);
setOtpSecret(result.otpSecret || '');
setQrCodeURL(result.qrCodeURL || '');
setStep('setup-otp');
} else {
setError(result.message || t('registrationFailed', language));
}
setLoading(false);
};
const handleSetupComplete = () => {
setStep('verify-otp');
};
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await completeRegistration(userID, otpCode);
if (!result.success) {
setError(result.message || t('registrationFailed', language));
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
<div className="w-full max-w-md">
{/* Back to Home */}
{step === 'register' && (
<button
onClick={() => {
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
style={{ color: '#848E9C' }}
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 object-contain" />
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
{/* Registration Form */}
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('registerButton', language)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="space-y-3">
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step1Title', language)}
</p>
<p className="text-xs" style={{ color: '#848E9C' }}>
{t('step1Desc', language)}
</p>
</div>
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('step2Desc', language)}
</p>
{qrCodeURL && (
<div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
<div className="bg-white p-2 rounded text-center">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code" className="mx-auto" />
</div>
</div>
)}
<div className="mt-2">
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{ background: '#2B3139', color: '#EAECEF' }}>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('copy', language)}
</button>
</div>
</div>
</div>
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step3Title', language)}
</p>
<p className="text-xs" style={{ color: '#848E9C' }}>
{t('step3Desc', language)}
</p>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('setupCompleteContinue', language)}
</button>
</div>
)}
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('completeRegistration', language)}
</button>
</div>
</form>
)}
</div>
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: '#848E9C' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline"
style={{ color: '#F0B90B' }}
>
</button>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,494 @@
import { useState, useEffect } from 'react';
import type { AIModel, Exchange, CreateTraderRequest } from '../types';
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
}
interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
system_prompt_template: string;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
}
interface TraderConfigModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
isEditMode?: boolean;
availableModels?: AIModel[];
availableExchanges?: Exchange[];
onSave?: (data: CreateTraderRequest) => Promise<void>;
}
export function TraderConfigModal({
isOpen,
onClose,
traderData,
isEditMode = false,
availableModels = [],
availableExchanges = [],
onSave
}: TraderConfigModalProps) {
const [formData, setFormData] = useState<TraderConfigData>({
trader_name: '',
ai_model: '',
exchange_id: '',
btc_eth_leverage: 5,
altcoin_leverage: 3,
trading_symbols: '',
custom_prompt: '',
override_base_prompt: false,
system_prompt_template: 'default',
is_cross_margin: true,
use_coin_pool: false,
use_oi_top: false,
initial_balance: 1000,
});
const [isSaving, setIsSaving] = useState(false);
const [availableCoins, setAvailableCoins] = useState<string[]>([]);
const [selectedCoins, setSelectedCoins] = useState<string[]>([]);
const [showCoinSelector, setShowCoinSelector] = useState(false);
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]);
useEffect(() => {
if (traderData) {
setFormData(traderData);
// 设置已选择的币种
if (traderData.trading_symbols) {
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s);
setSelectedCoins(coins);
}
} else if (!isEditMode) {
setFormData({
trader_name: '',
ai_model: availableModels[0]?.id || '',
exchange_id: availableExchanges[0]?.id || '',
btc_eth_leverage: 5,
altcoin_leverage: 3,
trading_symbols: '',
custom_prompt: '',
override_base_prompt: false,
system_prompt_template: 'default',
is_cross_margin: true,
use_coin_pool: false,
use_oi_top: false,
initial_balance: 1000,
});
}
// 确保旧数据也有默认的 system_prompt_template
if (traderData && !traderData.system_prompt_template) {
setFormData(prev => ({
...prev,
system_prompt_template: 'default'
}));
}
}, [traderData, isEditMode, availableModels, availableExchanges]);
// 获取系统配置中的币种列表
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
if (config.default_coins) {
setAvailableCoins(config.default_coins);
}
} catch (error) {
console.error('Failed to fetch config:', error);
// 使用默认币种列表
setAvailableCoins(['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT', 'ADAUSDT']);
}
};
fetchConfig();
}, []);
// 获取系统提示词模板列表
useEffect(() => {
const fetchPromptTemplates = async () => {
try {
const response = await fetch('/api/prompt-templates');
const data = await response.json();
if (data.templates) {
setPromptTemplates(data.templates);
}
} catch (error) {
console.error('Failed to fetch prompt templates:', error);
// 使用默认模板列表
setPromptTemplates([{name: 'default'}, {name: 'aggressive'}]);
}
};
fetchPromptTemplates();
}, []);
// 当选择的币种改变时,更新输入框
useEffect(() => {
const symbolsString = selectedCoins.join(',');
setFormData(prev => ({ ...prev, trading_symbols: symbolsString }));
}, [selectedCoins]);
if (!isOpen) return null;
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 如果是直接编辑trading_symbols同步更新selectedCoins
if (field === 'trading_symbols') {
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s);
setSelectedCoins(coins);
}
};
const handleCoinToggle = (coin: string) => {
setSelectedCoins(prev => {
if (prev.includes(coin)) {
return prev.filter(c => c !== coin);
} else {
return [...prev, coin];
}
});
};
const handleSave = async () => {
if (!onSave) return;
setIsSaving(true);
try {
const saveData: CreateTraderRequest = {
name: formData.trader_name,
ai_model_id: formData.ai_model,
exchange_id: formData.exchange_id,
btc_eth_leverage: formData.btc_eth_leverage,
altcoin_leverage: formData.altcoin_leverage,
trading_symbols: formData.trading_symbols,
custom_prompt: formData.custom_prompt,
override_base_prompt: formData.override_base_prompt,
system_prompt_template: formData.system_prompt_template,
is_cross_margin: formData.is_cross_margin,
use_coin_pool: formData.use_coin_pool,
use_oi_top: formData.use_oi_top,
initial_balance: formData.initial_balance,
};
await onSave(saveData);
onClose();
} catch (error) {
console.error('保存失败:', error);
} finally {
setIsSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center">
<span className="text-lg">{isEditMode ? '✏️' : ''}</span>
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
{isEditMode ? '修改交易员' : '创建交易员'}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? '修改交易员配置参数' : '配置新的AI交易员'}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
>
</button>
</div>
{/* Content */}
<div className="p-6 space-y-8">
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
🤖
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<input
type="text"
value={formData.trader_name}
onChange={(e) => handleInputChange('trader_name', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="请输入交易员名称"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">AI模型</label>
<select
value={formData.ai_model}
onChange={(e) => handleInputChange('ai_model', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableModels.map(model => (
<option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()}
</option>
))}
</select>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<select
value={formData.exchange_id}
onChange={(e) => handleInputChange('exchange_id', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableExchanges.map(exchange => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.id).toUpperCase()}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Trading Configuration */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
</h3>
<div className="space-y-4">
{/* 第一行:保证金模式和初始余额 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
</button>
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', false)}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
</button>
</div>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"> ($)</label>
<input
type="number"
value={formData.initial_balance}
onChange={(e) => handleInputChange('initial_balance', Number(e.target.value))}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="100"
/>
</div>
</div>
{/* 第二行:杠杆设置 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">BTC/ETH </label>
<input
type="number"
value={formData.btc_eth_leverage}
onChange={(e) => handleInputChange('btc_eth_leverage', Number(e.target.value))}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="125"
/>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<input
type="number"
value={formData.altcoin_leverage}
onChange={(e) => handleInputChange('altcoin_leverage', Number(e.target.value))}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="75"
/>
</div>
</div>
{/* 第三行:交易币种 */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]"> (使)</label>
<button
type="button"
onClick={() => setShowCoinSelector(!showCoinSelector)}
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors"
>
{showCoinSelector ? '收起选择' : '快速选择'}
</button>
</div>
<input
type="text"
value={formData.trading_symbols}
onChange={(e) => handleInputChange('trading_symbols', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
/>
{/* 币种选择器 */}
{showCoinSelector && (
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
<div className="text-xs text-[#848E9C] mb-2"></div>
<div className="flex flex-wrap gap-2">
{availableCoins.map(coin => (
<button
key={coin}
type="button"
onClick={() => handleCoinToggle(coin)}
className={`px-2 py-1 text-xs rounded transition-colors ${
selectedCoins.includes(coin)
? 'bg-[#F0B90B] text-black'
: 'bg-[#1E2329] text-[#848E9C] border border-[#2B3139] hover:border-[#F0B90B]'
}`}
>
{coin.replace('USDT', '')}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Signal Sources */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
📡
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.use_coin_pool}
onChange={(e) => handleInputChange('use_coin_pool', e.target.checked)}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 Coin Pool </label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.use_oi_top}
onChange={(e) => handleInputChange('use_oi_top', e.target.checked)}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 OI Top </label>
</div>
</div>
</div>
{/* Trading Prompt */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
💬
</h3>
<div className="space-y-4">
{/* 系统提示词模板选择 */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<select
value={formData.system_prompt_template}
onChange={(e) => handleInputChange('system_prompt_template', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{promptTemplates.map(template => (
<option key={template.name} value={template.name}>
{template.name === 'default' ? 'Default (默认稳健)' :
template.name === 'aggressive' ? 'Aggressive (激进)' :
template.name.charAt(0).toUpperCase() + template.name.slice(1)}
</option>
))}
</select>
<p className="text-xs text-[#848E9C] mt-1">
</p>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.override_base_prompt}
onChange={(e) => handleInputChange('override_base_prompt', e.target.checked)}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]"></label>
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg> </span>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'}
</label>
<textarea
value={formData.custom_prompt}
onChange={(e) => handleInputChange('custom_prompt', e.target.value)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
placeholder={formData.override_base_prompt ? "输入完整的交易策略提示词..." : "输入额外的交易策略提示..."}
/>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<button
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
>
</button>
{onSave && (
<button
onClick={handleSave}
disabled={isSaving || !formData.trader_name || !formData.ai_model || !formData.exchange_id}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? '保存中...' : (isEditMode ? '保存修改' : '创建交易员')}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import type { TraderConfigData } from '../types';
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
}
interface TraderConfigViewModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
}
export function TraderConfigViewModal({
isOpen,
onClose,
traderData
}: TraderConfigViewModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
if (!isOpen || !traderData) return null;
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
<button
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
style={{
background: copiedField === fieldName ? 'rgba(14, 203, 129, 0.1)' : 'rgba(240, 185, 11, 0.1)',
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`
}}
>
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
</button>
);
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: {
label: string;
value: string | number | boolean;
copyable?: boolean;
fieldName?: string;
}) => (
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
<div className="flex items-center text-right">
<span className="text-sm text-[#EAECEF] font-mono">
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
</span>
{copyable && typeof value === 'string' && value && (
<CopyButton text={value} fieldName={fieldName} />
)}
</div>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center">
<span className="text-lg">👁</span>
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{traderData.trader_name}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Running Status */}
<div
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
style={traderData.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
<span>{traderData.is_running ? '●' : '○'}</span>
{traderData.is_running ? '运行中' : '已停止'}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
>
</button>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
🤖
</h3>
<div className="space-y-3">
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" />
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" />
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} />
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} />
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} />
</div>
</div>
{/* Trading Configuration */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
</h3>
<div className="space-y-3">
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} />
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} />
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} />
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
copyable
fieldName="trading_symbols"
/>
</div>
</div>
{/* Signal Sources */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
📡
</h3>
<div className="space-y-3">
<InfoRow label="Coin Pool 信号" value={traderData.use_coin_pool} />
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
</div>
</div>
{/* Custom Prompt */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[#EAECEF] flex items-center gap-2">
💬
</h3>
{traderData.custom_prompt && (
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" />
)}
</div>
<div className="space-y-3">
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} />
{traderData.custom_prompt ? (
<div>
<div className="text-sm text-[#848E9C] mb-2">
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'}
</div>
<div
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
whiteSpace: 'pre-wrap'
}}
>
{traderData.custom_prompt}
</div>
</div>
) : (
<div className="text-sm text-[#848E9C] italic p-3 rounded border" style={{ border: '1px solid #2B3139' }}>
使
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<button
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
>
</button>
<button
onClick={() => copyToClipboard(JSON.stringify(traderData, null, 2), 'full_config')}
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
>
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useMemo, useRef, useState } from 'react'
interface TypewriterProps {
lines: string[]
typingSpeed?: number // 毫秒/字符
lineDelay?: number // 每行结束的额外等待
className?: string
style?: React.CSSProperties
}
export default function Typewriter({
lines,
typingSpeed = 50,
lineDelay = 600,
className,
style,
}: TypewriterProps) {
const [typedLines, setTypedLines] = useState<string[]>([''])
const [showCursor, setShowCursor] = useState(true)
const lineIndexRef = useRef(0)
const charIndexRef = useRef(0)
const timerRef = useRef<number | null>(null)
const blinkRef = useRef<number | null>(null)
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
useEffect(() => {
function typeNext() {
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
if (charIndexRef.current < currentLine.length) {
setTypedLines((prev) => {
const next = [...prev]
const ch = currentLine.charAt(charIndexRef.current)
next[next.length - 1] = (next[next.length - 1] || '') + ch
return next
})
charIndexRef.current += 1
timerRef.current = window.setTimeout(typeNext, typingSpeed)
} else {
// 行结束
if (lineIndexRef.current < sanitizedLines.length - 1) {
lineIndexRef.current += 1
charIndexRef.current = 0
setTypedLines((prev) => [...prev, ''])
timerRef.current = window.setTimeout(typeNext, lineDelay)
} else {
// 最后一行输入完毕
timerRef.current = null
}
}
}
typeNext()
// 光标闪烁
blinkRef.current = window.setInterval(() => {
setShowCursor((v) => !v)
}, 500)
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current)
if (blinkRef.current) window.clearInterval(blinkRef.current)
}
}, [lines, typingSpeed, lineDelay])
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
return (
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
{displayText}
<span style={{ opacity: showCursor ? 1 : 0 }}> </span>
</pre>
)
}

View File

@@ -0,0 +1,123 @@
import { motion } from 'framer-motion'
import { Shield, Target } from 'lucide-react'
import AnimatedSection from './AnimatedSection'
import Typewriter from '../Typewriter'
export default function AboutSection() {
return (
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
<motion.div
className='space-y-6'
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full'
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05 }}
>
<Target
className='w-4 h-4'
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className='text-sm font-semibold'
style={{ color: 'var(--brand-yellow)' }}
>
NOFX
</span>
</motion.div>
<h2
className='text-4xl font-bold'
style={{ color: 'var(--brand-light-gray)' }}
>
NOFX
</h2>
<p
className='text-lg leading-relaxed'
style={{ color: 'var(--text-secondary)' }}
>
NOFX AI 'Linux'
OS '决策-风险-执行'
</p>
<p
className='text-lg leading-relaxed'
style={{ color: 'var(--text-secondary)' }}
>
24/7AI
CodeFi PR
</p>
<motion.div
className='flex items-center gap-3 pt-4'
whileHover={{ x: 5 }}
>
<div
className='w-12 h-12 rounded-full flex items-center justify-center'
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
>
<Shield
className='w-6 h-6'
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
<div>
<div
className='font-semibold'
style={{ color: 'var(--brand-light-gray)' }}
>
100%
</div>
<div
className='text-sm'
style={{ color: 'var(--text-secondary)' }}
>
AI
</div>
</div>
</motion.div>
</motion.div>
<div className='relative'>
<div
className='rounded-2xl p-8'
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<Typewriter
lines={[
'$ git clone https://github.com/tinkle-community/nofx.git',
'$ cd nofx',
'$ chmod +x start.sh',
'$ ./start.sh start --build',
' 启动自动交易系统...',
' API服务器启动在端口 8080',
' Web 控制台 http://localhost:3000',
]}
typingSpeed={70}
lineDelay={900}
className='text-sm font-mono'
style={{
color: '#00FF41',
textShadow: '0 0 6px rgba(0,255,65,0.6)',
}}
/>
</div>
</div>
</div>
</div>
</AnimatedSection>
)
}

View File

@@ -1,5 +1,30 @@
export default function AnimatedSection({ children }: { children: React.ReactNode }) {
// 轻量容器:统一间距与可读性,避免引入额外依赖
return <section className='py-14 md:py-20'>{children}</section>
import { useRef } from 'react'
import { motion, useInView } from 'framer-motion'
export default function AnimatedSection({
children,
id,
backgroundColor = 'var(--brand-black)',
}: {
children: React.ReactNode
id?: string
backgroundColor?: string
}) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-100px' })
return (
<motion.section
id={id}
ref={ref}
className='py-20 px-4'
style={{ background: backgroundColor }}
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.6 }}
>
{children}
</motion.section>
)
}

View File

@@ -1,16 +1,7 @@
import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection'
type CardProps = {
quote: string
authorName: string
handle: string
avatarUrl: string
tweetUrl?: string
delay?: number
}
function TestimonialCard({ quote, authorName, handle, avatarUrl, tweetUrl, delay = 0 }: CardProps) {
function TestimonialCard({ quote, author, delay }: any) {
return (
<motion.div
className='p-6 rounded-xl'
@@ -19,32 +10,16 @@ function TestimonialCard({ quote, authorName, handle, avatarUrl, tweetUrl, delay
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay }}
whileHover={{ scale: 1.02 }}
whileHover={{ scale: 1.05 }}
>
<p className='text-lg mb-4 leading-relaxed' style={{ color: 'var(--brand-light-gray)' }}>
{quote}
<p className='text-lg mb-4' style={{ color: 'var(--brand-light-gray)' }}>
"{quote}"
</p>
<div className='flex items-center gap-3'>
{/* 头像:优先使用传入头像,失败则退回到首字母头像 */}
<img
src={avatarUrl}
alt={`${authorName} avatar`}
className='w-8 h-8 rounded-full object-cover'
onError={(e) => {
const target = e.currentTarget as HTMLImageElement
target.onerror = null
target.src = `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(authorName)}`
}}
/>
{tweetUrl ? (
<a href={tweetUrl} target='_blank' rel='noopener noreferrer' className='text-sm font-semibold hover:underline' style={{ color: 'var(--text-secondary)' }}>
{authorName} ({handle})
</a>
) : (
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
{authorName} ({handle})
</span>
)}
<div className='flex items-center gap-2'>
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
{author}
</span>
</div>
</motion.div>
)

View File

@@ -0,0 +1,56 @@
import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection'
import { CryptoFeatureCard } from '../CryptoFeatureCard'
import { Code, Cpu, Lock, Rocket } from 'lucide-react'
export default function FeaturesSection() {
return (
<AnimatedSection id='features'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
whileHover={{ scale: 1.05 }}
>
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
</span>
</motion.div>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
NOFX
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
AI
</p>
</motion.div>
<div className='grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto'>
<CryptoFeatureCard
icon={<Code className='w-8 h-8' />}
title='100% 开源与自托管'
description='你的框架,你的规则。非黑箱,支持自定义提示词和多模型。'
features={['完全开源代码', '支持自托管部署', '自定义 AI 提示词', '多模型支持DeepSeek、Qwen']}
delay={0}
/>
<CryptoFeatureCard
icon={<Cpu className='w-8 h-8' />}
title='多代理智能竞争'
description='AI 策略在沙盒中高速战斗,最优者生存,实现策略进化。'
features={['多 AI 代理并行运行', '策略自动优化', '沙盒安全测试', '跨市场策略移植']}
delay={0.1}
/>
<CryptoFeatureCard
icon={<Lock className='w-8 h-8' />}
title='安全可靠交易'
description='企业级安全保障,完全掌控你的资金和交易策略。'
features={['本地私钥管理', 'API 权限精细控制', '实时风险监控', '交易日志审计']}
delay={0.2}
/>
</div>
</div>
</AnimatedSection>
)
}

View File

@@ -0,0 +1,169 @@
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
export default function FooterSection() {
const { language } = useLanguage()
return (
<footer style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}>
<div className='max-w-[1200px] mx-auto px-6 py-10'>
{/* Brand */}
<div className='flex items-center gap-3 mb-8'>
<img src='/images/logo.png' alt='NOFX Logo' className='w-8 h-8' />
<div>
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}>
NOFX
</div>
<div className='text-xs' style={{ color: '#848E9C' }}>
AI
</div>
</div>
</div>
{/* Multi-link columns */}
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8'>
<div>
<h3
className='text-sm font-semibold mb-3'
style={{ color: '#EAECEF' }}
>
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx'
target='_blank'
rel='noopener noreferrer'
>
GitHub
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://t.me/nofx_dev_community'
target='_blank'
rel='noopener noreferrer'
>
Telegram
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://x.com/nofx_ai'
target='_blank'
rel='noopener noreferrer'
>
X (Twitter)
</a>
</li>
</ul>
</div>
<div>
<h3
className='text-sm font-semibold mb-3'
style={{ color: '#EAECEF' }}
>
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/blob/main/README.md'
target='_blank'
rel='noopener noreferrer'
>
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/issues'
target='_blank'
rel='noopener noreferrer'
>
Issues
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/pulls'
target='_blank'
rel='noopener noreferrer'
>
Pull Requests
</a>
</li>
</ul>
</div>
<div>
<h3
className='text-sm font-semibold mb-3'
style={{ color: '#EAECEF' }}
>
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://asterdex.com/'
target='_blank'
rel='noopener noreferrer'
>
Aster DEX
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://www.binance.com/'
target='_blank'
rel='noopener noreferrer'
>
Binance
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://hyperliquid.xyz/'
target='_blank'
rel='noopener noreferrer'
>
Hyperliquid
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://amber.ac/'
target='_blank'
rel='noopener noreferrer'
>
Amber.ac <span className='opacity-70'>()</span>
</a>
</li>
</ul>
</div>
</div>
{/* Bottom note (kept subtle) */}
<div
className='pt-6 mt-8 text-center text-xs'
style={{ color: '#5E6673', borderTop: '1px solid #2B3139' }}
>
<p>{t('footerTitle', language)}</p>
<p className='mt-1'>{t('footerWarning', language)}</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Menu, X } from 'lucide-react'
export default function HeaderBar({ onLoginClick }: { onLoginClick: () => void }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<nav className='fixed top-0 w-full z-50 header-bar'>
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
<div className='flex items-center justify-between h-16'>
{/* Logo */}
<div className='flex items-center gap-3'>
<img src='/images/logo.png' alt='NOFX Logo' className='w-8 h-8' />
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}>
NOFX
</span>
<span className='text-sm hidden sm:block' style={{ color: 'var(--text-secondary)' }}>
Agentic Trading OS
</span>
</div>
{/* Desktop Menu */}
<div className='hidden md:flex items-center gap-6'>
{['功能', '如何运作', 'GitHub', '社区'].map((item) => (
<a
key={item}
href={
item === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item === '社区'
? 'https://t.me/nofx_dev_community'
: `#${item === '功能' ? 'features' : 'how-it-works'}`
}
target={item === 'GitHub' || item === '社区' ? '_blank' : undefined}
rel={item === 'GitHub' || item === '社区' ? 'noopener noreferrer' : undefined}
className='text-sm transition-colors relative group'
style={{ color: 'var(--brand-light-gray)' }}
>
{item}
<span
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300'
style={{ background: 'var(--brand-yellow)' }}
/>
</a>
))}
<button
onClick={onLoginClick}
className='px-4 py-2 rounded font-semibold text-sm'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
/
</button>
</div>
{/* Mobile Menu Button */}
<motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className='md:hidden'
style={{ color: 'var(--brand-light-gray)' }}
whileTap={{ scale: 0.9 }}
>
{mobileMenuOpen ? <X className='w-6 h-6' /> : <Menu className='w-6 h-6' />}
</motion.button>
</div>
</div>
{/* Mobile Menu */}
<motion.div
initial={false}
animate={mobileMenuOpen ? { height: 'auto', opacity: 1 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className='md:hidden overflow-hidden'
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }}
>
<div className='px-4 py-4 space-y-3'>
{['功能', '如何运作', 'GitHub', '社区'].map((item) => (
<a key={item} href={`#${item}`} className='block text-sm py-2' style={{ color: 'var(--brand-light-gray)' }}>
{item}
</a>
))}
<button
onClick={() => {
onLoginClick()
setMobileMenuOpen(false)
}}
className='w-full px-4 py-2 rounded font-semibold text-sm mt-2'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
/
</button>
</div>
</motion.div>
</nav>
)
}

View File

@@ -0,0 +1,82 @@
import { motion, useScroll, useTransform } from 'framer-motion'
import { Sparkles } from 'lucide-react'
export default function HeroSection() {
const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
const fadeInUp = {
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] },
}
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
return (
<section className='relative pt-32 pb-20 px-4'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
{/* Left Content */}
<motion.div className='space-y-6 relative z-10' style={{ opacity, scale }} initial='initial' animate='animate' variants={staggerContainer}>
<motion.div variants={fadeInUp}>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }}
>
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
3 2.5K+ GitHub Stars
</span>
</motion.div>
</motion.div>
<h1 className='text-5xl lg:text-7xl font-bold leading-tight' style={{ color: 'var(--brand-light-gray)' }}>
Read the Market.
<br />
<span style={{ color: 'var(--brand-yellow)' }}>Write the Trade.</span>
</h1>
<motion.p className='text-xl leading-relaxed' style={{ color: 'var(--text-secondary)' }} variants={fadeInUp}>
NOFX AI BinanceAster DEX
AI
</motion.p>
<div className='flex items-center gap-3 flex-wrap'>
<motion.a href='https://github.com/tinkle-community/nofx' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<img
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
alt='GitHub Stars'
className='h-7'
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/network/members' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<img
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
alt='GitHub Forks'
className='h-7'
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/graphs/contributors' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<img
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
alt='GitHub Contributors'
className='h-7'
/>
</motion.a>
</div>
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}>
Aster DEX Binance Amber.ac
</motion.p>
</motion.div>
{/* Right Visual */}
<motion.img src='/images/main.png' alt='NOFX Platform' className='w-full opacity-90' whileHover={{ scale: 1.05, rotate: 5 }} transition={{ type: 'spring', stiffness: 300 }} />
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,74 @@
import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection'
function StepCard({ number, title, description, delay }: any) {
return (
<motion.div className='flex gap-6 items-start' initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay }} whileHover={{ x: 10 }}>
<motion.div
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl'
style={{ background: 'var(--binance-yellow)', color: 'var(--brand-black)' }}
whileHover={{ scale: 1.2, rotate: 360 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
>
{number}
</motion.div>
<div>
<h3 className='text-2xl font-semibold mb-2' style={{ color: 'var(--brand-light-gray)' }}>
{title}
</h3>
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}>
{description}
</p>
</div>
</motion.div>
)
}
export default function HowItWorksSection() {
return (
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
使 NOFX
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
AI
</p>
</motion.div>
<div className='space-y-8'>
{[
{ number: 1, title: '拉取 GitHub 仓库', description: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。' },
{ number: 2, title: '配置环境', description: '前端设置交易所 API如 Binance、Hyperliquid、AI 模型和自定义提示词。' },
{ number: 3, title: '部署与运行', description: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。' },
{ number: 4, title: '优化与贡献', description: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。' },
].map((step, index) => (
<StepCard key={step.number} {...step} delay={index * 0.1} />
))}
</div>
<motion.div
className='mt-12 p-6 rounded-xl flex items-start gap-4'
style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className='w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0' style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}>
<svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><path d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z'/><line x1='12' x2='12' y1='9' y2='13'/><line x1='12' x2='12.01' y1='17' y2='17'/></svg>
</div>
<div>
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}>
</div>
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}>
dev NOFX
</p>
</div>
</motion.div>
</div>
</AnimatedSection>
)
}

View File

@@ -0,0 +1,63 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
export default function LoginModal({ onClose }: { onClose: () => void }) {
return (
<motion.div
className='fixed inset-0 z-50 flex items-center justify-center p-4'
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className='relative max-w-md w-full rounded-2xl p-8'
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
initial={{ scale: 0.9, y: 50 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 50 }}
onClick={(e) => e.stopPropagation()}
>
<motion.button onClick={onClose} className='absolute top-4 right-4' style={{ color: 'var(--text-secondary)' }} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<X className='w-6 h-6' />
</motion.button>
<h2 className='text-2xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }}>
访 NOFX
</h2>
<p className='text-sm mb-6' style={{ color: 'var(--text-secondary)' }}>
访 AI
</p>
<div className='space-y-3'>
<motion.button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
<motion.button
onClick={() => {
window.history.pushState({}, '', '/register')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</motion.div>
</motion.div>
)
}

View File

@@ -0,0 +1,217 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getSystemConfig } from '../lib/config';
interface User {
id: string;
email: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
register: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig()
.then(data => {
if (data.admin_mode) {
// 管理员模式下模拟admin用户
setUser({ id: 'admin', email: 'admin@localhost' });
setToken('admin-mode');
} else {
// 非管理员模式检查本地存储中是否有token
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}
setIsLoading(false);
})
.catch(err => {
console.error('Failed to fetch system config:', err);
// 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
setIsLoading(false);
});
}, []);
const login = async (email: string, password: string) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
if (data.requires_otp) {
return {
success: true,
userID: data.user_id,
requiresOTP: true,
message: data.message,
};
}
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '登录失败,请重试' };
}
return { success: false, message: '未知错误' };
};
const register = async (email: string, password: string) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
return {
success: true,
userID: data.user_id,
otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url,
message: data.message,
};
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '注册失败,请重试' };
}
};
const verifyOTP = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
const data = await response.json();
if (response.ok) {
// 登录成功保存token和用户信息
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
// 跳转到首页
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
return { success: true, message: data.message };
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: 'OTP验证失败请重试' };
}
};
const completeRegistration = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
const data = await response.json();
if (response.ok) {
// 注册完成,自动登录
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
// 跳转到首页
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
return { success: true, message: data.message };
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '注册完成失败,请重试' };
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
};
return (
<AuthContext.Provider
value={{
user,
token,
login,
register,
verifyOTP,
completeRegistration,
logout,
isLoading,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { getSystemConfig, type SystemConfig } from '../lib/config';
export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
getSystemConfig()
.then((data) => {
if (!mounted) return;
setConfig(data);
setLoading(false);
})
.catch((err: Error) => {
if (!mounted) return;
console.error('Failed to fetch system config:', err);
setError(err.message);
setLoading(false);
});
return () => {
mounted = false;
};
}, []);
return { config, loading, error };
}

View File

@@ -3,15 +3,21 @@ export type Language = 'en' | 'zh';
export const translations = {
en: {
// Header
appTitle: 'AI Trading Competition',
subtitle: 'Qwen vs DeepSeek · Real-time',
competition: 'Competition',
appTitle: 'NOFX',
subtitle: 'Multi-AI Model Trading Platform',
aiTraders: 'AI Traders',
details: 'Details',
tradingPanel: 'Trading Panel',
competition: 'Competition',
running: 'RUNNING',
stopped: 'STOPPED',
adminMode: 'Admin Mode',
logout: 'Logout',
switchTrader: 'Switch Trader:',
view: 'View',
// Footer
footerTitle: 'NOFX - AI Trading Competition System',
footerTitle: 'NOFX - AI Trading System',
footerWarning: '⚠️ Trading involves risk. Use at your own discretion.',
// Stats Cards
@@ -63,16 +69,25 @@ export const translations = {
recent: 'Recent',
allData: 'All Data',
cycles: 'Cycles',
// Comparison Chart
comparisonMode: 'Comparison Mode',
dataPoints: 'Data Points',
currentGap: 'Current Gap',
count: '{count} pts',
// Competition Page
aiCompetition: 'AI Competition',
traders: 'traders',
liveBattle: 'Qwen vs DeepSeek · Live Battle',
liveBattle: 'Live Battle',
realTimeBattle: 'Real-time Battle',
leader: 'Leader',
leaderboard: 'Leaderboard',
live: 'LIVE',
realTime: 'LIVE',
performanceComparison: 'Performance Comparison',
realTimePnL: 'Real-time PnL %',
realTimePnLPercent: 'Real-time PnL %',
headToHead: 'Head-to-Head Battle',
leadingBy: 'Leading by {gap}%',
behindBy: 'Behind by {gap}%',
@@ -114,22 +129,212 @@ export const translations = {
aiLearningPoint3: 'Optimizes position sizing based on win rate',
aiLearningPoint4: 'Avoids repeating past mistakes',
// AI Traders Management
manageAITraders: 'Manage your AI trading bots',
aiModels: 'AI Models',
exchanges: 'Exchanges',
createTrader: 'Create Trader',
modelConfiguration: 'Model Configuration',
configured: 'Configured',
notConfigured: 'Not Configured',
currentTraders: 'Current Traders',
noTraders: 'No AI Traders',
createFirstTrader: 'Create your first AI trader to get started',
configureModelsFirst: 'Please configure AI models first',
configureExchangesFirst: 'Please configure exchanges first',
configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first',
modelNotConfigured: 'Selected model is not configured',
exchangeNotConfigured: 'Selected exchange is not configured',
confirmDeleteTrader: 'Are you sure you want to delete this trader?',
status: 'Status',
start: 'Start',
stop: 'Stop',
createNewTrader: 'Create New AI Trader',
selectAIModel: 'Select AI Model',
selectExchange: 'Select Exchange',
traderName: 'Trader Name',
enterTraderName: 'Enter trader name',
cancel: 'Cancel',
create: 'Create',
configureAIModels: 'Configure AI Models',
configureExchanges: 'Configure Exchanges',
useTestnet: 'Use Testnet',
enabled: 'Enabled',
save: 'Save',
// AI Model Configuration
officialAPI: 'Official API',
customAPI: 'Custom API',
apiKey: 'API Key',
customAPIURL: 'Custom API URL',
enterAPIKey: 'Enter API Key',
enterCustomAPIURL: 'Enter custom API endpoint URL',
useOfficialAPI: 'Use official API service',
useCustomAPI: 'Use custom API endpoint',
// Exchange Configuration
secretKey: 'Secret Key',
privateKey: 'Private Key',
walletAddress: 'Wallet Address',
user: 'User',
signer: 'Signer',
passphrase: 'Passphrase',
enterPrivateKey: 'Enter Private Key',
enterWalletAddress: 'Enter Wallet Address',
enterUser: 'Enter User',
enterSigner: 'Enter Signer Address',
enterSecretKey: 'Enter Secret Key',
enterPassphrase: 'Enter Passphrase (Required for OKX)',
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication',
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key',
testnetDescription: 'Enable to connect to exchange test environment for simulated trading',
securityWarning: 'Security Warning',
saveConfiguration: 'Save Configuration',
// Trader Configuration
positionMode: 'Position Mode',
crossMarginMode: 'Cross Margin',
isolatedMarginMode: 'Isolated Margin',
crossMarginDescription: 'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation',
leverageConfiguration: 'Leverage Configuration',
btcEthLeverage: 'BTC/ETH Leverage',
altcoinLeverage: 'Altcoin Leverage',
leverageRecommendation: 'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
tradingSymbols: 'Trading Symbols',
tradingSymbolsPlaceholder: 'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
selectSymbols: 'Select Symbols',
selectTradingSymbols: 'Select Trading Symbols',
selectedSymbolsCount: 'Selected {count} symbols',
clearSelection: 'Clear All',
confirmSelection: 'Confirm',
tradingSymbolsDescription: 'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
// Loading & Error
loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data',
noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
// AI Traders Page - Additional
inUse: 'In Use',
noModelsConfigured: 'No configured AI models',
noExchangesConfigured: 'No configured exchanges',
signalSource: 'Signal Source',
signalSourceConfig: 'Signal Source Configuration',
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source',
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source',
information: 'Information',
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources',
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals',
editAIModel: 'Edit AI Model',
addAIModel: 'Add AI Model',
confirmDeleteModel: 'Are you sure you want to delete this AI model configuration?',
selectModel: 'Select AI Model',
pleaseSelectModel: 'Please select a model',
customBaseURL: 'Base URL (Optional)',
customBaseURLPlaceholder: 'Custom API base URL, e.g.: https://api.openai.com/v1',
leaveBlankForDefault: 'Leave blank to use default API address',
modelConfigInfo1: '• API Key will be encrypted and stored, please ensure it is valid',
modelConfigInfo2: '• Base URL is used for custom API server address',
modelConfigInfo3: '• After deleting configuration, traders using this model will not work properly',
saveConfig: 'Save Configuration',
editExchange: 'Edit Exchange',
addExchange: 'Add Exchange',
confirmDeleteExchange: 'Are you sure you want to delete this exchange configuration?',
pleaseSelectExchange: 'Please select an exchange',
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade',
edit: 'Edit',
// Error Messages
createTraderFailed: 'Failed to create trader',
getTraderConfigFailed: 'Failed to get trader configuration',
modelConfigNotExist: 'Model configuration does not exist or is not enabled',
exchangeConfigNotExist: 'Exchange configuration does not exist or is not enabled',
updateTraderFailed: 'Failed to update trader',
deleteTraderFailed: 'Failed to delete trader',
operationFailed: 'Operation failed',
deleteConfigFailed: 'Failed to delete configuration',
modelNotExist: 'Model does not exist',
saveConfigFailed: 'Failed to save configuration',
exchangeNotExist: 'Exchange does not exist',
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
saveSignalSourceFailed: 'Failed to save signal source configuration',
// Login & Register
login: 'Sign In',
register: 'Sign Up',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
emailPlaceholder: 'your@email.com',
passwordPlaceholder: 'Enter your password',
confirmPasswordPlaceholder: 'Re-enter your password',
otpPlaceholder: '000000',
loginTitle: 'Sign in to your account',
registerTitle: 'Create a new account',
loginButton: 'Sign In',
registerButton: 'Sign Up',
back: 'Back',
noAccount: "Don't have an account?",
hasAccount: 'Already have an account?',
registerNow: 'Sign up now',
loginNow: 'Sign in now',
forgotPassword: 'Forgot password?',
rememberMe: 'Remember me',
otpCode: 'OTP Code',
scanQRCode: 'Scan QR Code',
enterOTPCode: 'Enter 6-digit OTP code',
verifyOTP: 'Verify OTP',
setupTwoFactor: 'Set up two-factor authentication',
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
otpSecret: 'Or enter this secret manually:',
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
step1Title: 'Step 1: Install Google Authenticator',
step1Desc: 'Download and install Google Authenticator from your app store',
step2Title: 'Step 2: Add account',
step2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
step3Title: 'Step 3: Verify setup',
step3Desc: 'After setup, continue to enter the 6-digit code',
setupCompleteContinue: 'I have completed setup, continue',
copy: 'Copy',
completeRegistration: 'Complete Registration',
completeRegistrationSubtitle: 'to complete registration',
loginSuccess: 'Login successful',
registrationSuccess: 'Registration successful',
loginFailed: 'Login failed',
registrationFailed: 'Registration failed',
verificationFailed: 'OTP verification failed',
invalidCredentials: 'Invalid email or password',
passwordMismatch: 'Passwords do not match',
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
invalidEmail: 'Invalid email format',
passwordTooShort: 'Password must be at least 6 characters',
},
zh: {
// Header
appTitle: 'AI交易竞赛',
subtitle: 'Qwen vs DeepSeek · 实时',
competition: '竞赛',
appTitle: 'NOFX',
subtitle: '多AI模型交易平台',
aiTraders: 'AI交易员',
details: '详情',
tradingPanel: '交易面板',
competition: '竞赛',
running: '运行中',
stopped: '已停止',
adminMode: '管理员模式',
logout: '退出',
switchTrader: '切换交易员:',
view: '查看',
// Footer
footerTitle: 'NOFX - AI交易竞赛系统',
footerTitle: 'NOFX - AI交易系统',
footerWarning: '⚠️ 交易有风险,请谨慎使用。',
// Stats Cards
@@ -181,22 +386,31 @@ export const translations = {
recent: '最近',
allData: '全部数据',
cycles: '个',
// Comparison Chart
comparisonMode: '对比模式',
dataPoints: '数据点数',
currentGap: '当前差距',
count: '{count} 个',
// Competition Page
aiCompetition: 'AI竞赛',
traders: '交易',
liveBattle: 'Qwen vs DeepSeek · 实时对战',
leader: '🥇 领先者',
leaderboard: '🥇 排行榜',
live: '直播',
performanceComparison: '📈 表现对比',
realTimePnL: '实时盈亏百分比',
headToHead: '⚔️ 正面对决',
traders: '交易',
liveBattle: '实时对战',
realTimeBattle: '实时对战',
leader: '领先者',
leaderboard: '排行榜',
live: '实时',
realTime: '实时',
performanceComparison: '表现对比',
realTimePnL: '实时收益率',
realTimePnLPercent: '实时收益率',
headToHead: '正面对决',
leadingBy: '领先 {gap}%',
behindBy: '落后 {gap}%',
equity: '净值',
pnl: '盈亏',
pos: '仓',
equity: '权益',
pnl: '收益',
pos: '仓',
// AI Learning
aiLearning: 'AI学习与反思',
@@ -232,10 +446,194 @@ export const translations = {
aiLearningPoint3: '根据胜率优化仓位大小',
aiLearningPoint4: '避免重复过去的错误',
// AI Traders Management
manageAITraders: '管理您的AI交易机器人',
aiModels: 'AI模型',
exchanges: '交易所',
createTrader: '创建交易员',
modelConfiguration: '模型配置',
configured: '已配置',
notConfigured: '未配置',
currentTraders: '当前交易员',
noTraders: '暂无AI交易员',
createFirstTrader: '创建您的第一个AI交易员开始使用',
configureModelsFirst: '请先配置AI模型',
configureExchangesFirst: '请先配置交易所',
configureModelsAndExchangesFirst: '请先配置AI模型和交易所',
modelNotConfigured: '所选模型未配置',
exchangeNotConfigured: '所选交易所未配置',
confirmDeleteTrader: '确定要删除这个交易员吗?',
status: '状态',
start: '启动',
stop: '停止',
createNewTrader: '创建新的AI交易员',
selectAIModel: '选择AI模型',
selectExchange: '选择交易所',
traderName: '交易员名称',
enterTraderName: '输入交易员名称',
cancel: '取消',
create: '创建',
configureAIModels: '配置AI模型',
configureExchanges: '配置交易所',
useTestnet: '使用测试网',
enabled: '启用',
save: '保存',
// AI Model Configuration
officialAPI: '官方API',
customAPI: '自定义API',
apiKey: 'API密钥',
customAPIURL: '自定义API地址',
enterAPIKey: '请输入API密钥',
enterCustomAPIURL: '请输入自定义API端点地址',
useOfficialAPI: '使用官方API服务',
useCustomAPI: '使用自定义API端点',
// Exchange Configuration
secretKey: '密钥',
privateKey: '私钥',
walletAddress: '钱包地址',
user: '用户名',
signer: '签名者',
passphrase: '口令',
enterSecretKey: '输入密钥',
enterPrivateKey: '输入私钥',
enterWalletAddress: '输入钱包地址',
enterUser: '输入用户名',
enterSigner: '输入签名者地址',
enterPassphrase: '输入Passphrase (OKX必填)',
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
securityWarning: '安全提示',
saveConfiguration: '保存配置',
// Trader Configuration
positionMode: '仓位模式',
crossMarginMode: '全仓模式',
isolatedMarginMode: '逐仓模式',
crossMarginDescription: '全仓模式:所有仓位共享账户余额作为保证金',
isolatedMarginDescription: '逐仓模式:每个仓位独立管理保证金,风险隔离',
leverageConfiguration: '杠杆配置',
btcEthLeverage: 'BTC/ETH杠杆',
altcoinLeverage: '山寨币杠杆',
leverageRecommendation: '推荐BTC/ETH 5-10倍山寨币 3-5倍控制风险',
tradingSymbols: '交易币种',
tradingSymbolsPlaceholder: '输入币种逗号分隔BTCUSDT,ETHUSDT,SOLUSDT',
selectSymbols: '选择币种',
selectTradingSymbols: '选择交易币种',
selectedSymbolsCount: '已选择 {count} 个币种',
clearSelection: '清空选择',
confirmSelection: '确认选择',
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾BTCUSDT, ETHUSDT',
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
invalidSymbolFormat: '无效的币种格式:{symbol}必须以USDT结尾',
// Loading & Error
loading: '加载中...',
loadingError: '⚠️ 加载AI学习数据失败',
noCompleteData: '暂无完整交易数据(需要完成开仓→平仓的完整周期)',
// AI Traders Page - Additional
inUse: '正在使用',
noModelsConfigured: '暂无已配置的AI模型',
noExchangesConfigured: '暂无已配置的交易所',
signalSource: '信号源',
signalSourceConfig: '信号源配置',
coinPoolDescription: '用于获取币种池数据的API地址留空则不使用此信号源',
oiTopDescription: '用于获取持仓量排行数据的API地址留空则不使用此信号源',
information: '说明',
signalSourceInfo1: '• 信号源配置为用户级别每个用户可以设置自己的信号源URL',
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
editAIModel: '编辑AI模型',
addAIModel: '添加AI模型',
confirmDeleteModel: '确定要删除此AI模型配置吗',
selectModel: '选择AI模型',
pleaseSelectModel: '请选择模型',
customBaseURL: 'Base URL (可选)',
customBaseURLPlaceholder: '自定义API基础URL如: https://api.openai.com/v1',
leaveBlankForDefault: '留空则使用默认API地址',
modelConfigInfo1: '• API Key将被加密存储请确保密钥有效',
modelConfigInfo2: '• Base URL用于自定义API服务器地址',
modelConfigInfo3: '• 删除配置后,使用此模型的交易员将无法正常工作',
saveConfig: '保存配置',
editExchange: '编辑交易所',
addExchange: '添加交易所',
confirmDeleteExchange: '确定要删除此交易所配置吗?',
pleaseSelectExchange: '请选择交易所',
exchangeConfigWarning1: '• API密钥将被加密存储建议使用只读或期货交易权限',
exchangeConfigWarning2: '• 不要授予提现权限,确保资金安全',
exchangeConfigWarning3: '• 删除配置后,相关交易员将无法正常交易',
edit: '编辑',
// Error Messages
createTraderFailed: '创建交易员失败',
getTraderConfigFailed: '获取交易员配置失败',
modelConfigNotExist: 'AI模型配置不存在或未启用',
exchangeConfigNotExist: '交易所配置不存在或未启用',
updateTraderFailed: '更新交易员失败',
deleteTraderFailed: '删除交易员失败',
operationFailed: '操作失败',
deleteConfigFailed: '删除配置失败',
modelNotExist: '模型不存在',
saveConfigFailed: '保存配置失败',
exchangeNotExist: '交易所不存在',
deleteExchangeConfigFailed: '删除交易所配置失败',
saveSignalSourceFailed: '保存信号源配置失败',
// Login & Register
login: '登录',
register: '注册',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码至少6位',
confirmPasswordPlaceholder: '请再次输入密码',
otpPlaceholder: '000000',
loginTitle: '登录到您的账户',
registerTitle: '创建新账户',
loginButton: '登录',
registerButton: '注册',
back: '返回',
noAccount: '还没有账户?',
hasAccount: '已有账户?',
registerNow: '立即注册',
loginNow: '立即登录',
forgotPassword: '忘记密码?',
rememberMe: '记住我',
otpCode: 'OTP验证码',
scanQRCode: '扫描二维码',
enterOTPCode: '输入6位OTP验证码',
verifyOTP: '验证OTP',
setupTwoFactor: '设置双因素认证',
setupTwoFactorDesc: '请按以下步骤设置Google验证器以保护您的账户安全',
scanQRCodeInstructions: '使用Google Authenticator或Authy扫描此二维码',
otpSecret: '或手动输入此密钥:',
qrCodeHint: '二维码(如果无法扫描,请使用下方密钥):',
step1Title: '步骤1下载Google Authenticator',
step1Desc: '在手机应用商店下载并安装Google Authenticator应用',
step2Title: '步骤2添加账户',
step2Desc: '在应用中点击“+”,选择“扫描二维码”或“手动输入密钥”',
step3Title: '步骤3验证设置',
step3Desc: '设置完成后点击下方按钮输入6位验证码',
setupCompleteContinue: '我已完成设置,继续',
copy: '复制',
completeRegistration: '完成注册',
completeRegistrationSubtitle: '以完成注册',
loginSuccess: '登录成功',
registrationSuccess: '注册成功',
loginFailed: '登录失败',
registrationFailed: '注册失败',
verificationFailed: 'OTP验证失败',
invalidCredentials: '邮箱或密码错误',
passwordMismatch: '两次输入的密码不一致',
emailRequired: '请输入邮箱',
passwordRequired: '请输入密码',
invalidEmail: '邮箱格式不正确',
passwordTooShort: '密码至少需要6个字符',
}
};

View File

@@ -6,13 +6,22 @@
@tailwind utilities;
:root {
/* Binance Brand Colors */
--brand-yellow: #F0B90B;
--brand-black: #0C0E12;
--brand-dark-gray: #1E2329;
--brand-light-gray: #EAECEF;
--brand-almost-white: #FAFAFA;
--brand-white: #FFFFFF;
/* Binance Theme Colors */
--binance-yellow: #F0B90B;
--binance-yellow-dark: #C99400;
--binance-yellow-light: #FCD535;
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
--background: #0B0E11;
--background: #181A20; /* Binance body bg */
--header-bg: #0B0E11; /* Binance header bg */
--background-elevated: #181A20;
--foreground: #EAECEF;
--panel-bg: #1E2329;
@@ -138,10 +147,10 @@ body {
@keyframes shimmer {
0% {
background-position: -200% 0;
transform: translateX(-100%);
}
100% {
background-position: 200% 0;
transform: translateX(100%);
}
}
@@ -171,12 +180,9 @@ body {
}
/* Glass effect - Binance header style */
.glass {
background: var(--background-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
.header-bar {
background: var(--header-bg);
border-bottom: 1px solid var(--panel-border);
box-shadow: var(--shadow-sm);
}
/* Monospace numbers */

View File

@@ -5,31 +5,159 @@ import type {
DecisionRecord,
Statistics,
TraderInfo,
AIModel,
Exchange,
CreateTraderRequest,
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types';
const API_BASE = '/api';
// Helper function to get auth headers
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
export const api = {
// 竞赛相关接口
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`);
if (!res.ok) throw new Error('获取竞赛数据失败');
// AI交易员管理接口
async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/traders`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取trader列表失败');
return res.json();
},
async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/traders`);
if (!res.ok) throw new Error('获取trader列表失败');
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('创建交易员失败');
return res.json();
},
async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('删除交易员失败');
},
async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('启动交易员失败');
},
async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('停止交易员失败');
},
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ custom_prompt: customPrompt }),
});
if (!res.ok) throw new Error('更新自定义策略失败');
},
async getTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易员配置失败');
return res.json();
},
async updateTrader(traderId: string, request: CreateTraderRequest): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易员失败');
return res.json();
},
// AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取模型配置失败');
return res.json();
},
// 获取系统支持的AI模型列表无需认证
async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`);
if (!res.ok) throw new Error('获取支持的模型失败');
return res.json();
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
const res = await fetch(`${API_BASE}/models`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新模型配置失败');
},
// 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易所配置失败');
return res.json();
},
// 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`);
if (!res.ok) throw new Error('获取支持的交易所失败');
return res.json();
},
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易所配置失败');
},
// 获取系统状态支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取系统状态失败');
return res.json();
},
@@ -42,6 +170,7 @@ export const api = {
const res = await fetch(url, {
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache',
},
});
@@ -56,7 +185,9 @@ export const api = {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取持仓列表失败');
return res.json();
},
@@ -66,7 +197,9 @@ export const api = {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取决策日志失败');
return res.json();
},
@@ -76,7 +209,9 @@ export const api = {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取最新决策失败');
return res.json();
},
@@ -86,7 +221,9 @@ export const api = {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取统计信息失败');
return res.json();
},
@@ -96,7 +233,9 @@ export const api = {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取历史数据失败');
return res.json();
},
@@ -106,8 +245,40 @@ export const api = {
const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取AI学习数据失败');
return res.json();
},
// 获取竞赛数据
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取竞赛数据失败');
return res.json();
},
// 用户信号源配置接口
async getUserSignalSource(): Promise<{coin_pool_url: string, oi_top_url: string}> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取用户信号源配置失败');
return res.json();
},
async saveUserSignalSource(coinPoolUrl: string, oiTopUrl: string): Promise<void> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl,
}),
});
if (!res.ok) throw new Error('保存用户信号源配置失败');
},
};

27
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface SystemConfig {
admin_mode: boolean;
}
let configPromise: Promise<SystemConfig> | null = null;
let cachedConfig: SystemConfig | null = null;
export function getSystemConfig(): Promise<SystemConfig> {
if (cachedConfig) {
return Promise.resolve(cachedConfig);
}
if (configPromise) {
return configPromise;
}
configPromise = fetch('/api/config')
.then((res) => res.json())
.then((data: SystemConfig) => {
cachedConfig = data;
return data;
})
.finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
});
return configPromise;
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { ArrowRight } from 'lucide-react'
import HeaderBar from '../components/landing/HeaderBar'
import HeroSection from '../components/landing/HeroSection'
import AboutSection from '../components/landing/AboutSection'
import FeaturesSection from '../components/landing/FeaturesSection'
import HowItWorksSection from '../components/landing/HowItWorksSection'
import CommunitySection from '../components/landing/CommunitySection'
import AnimatedSection from '../components/landing/AnimatedSection'
import LoginModal from '../components/landing/LoginModal'
import FooterSection from '../components/landing/FooterSection'
export function LandingPage() {
const [showLoginModal, setShowLoginModal] = useState(false)
return (
<div className='min-h-screen overflow-hidden' style={{ background: 'var(--brand-black)', color: 'var(--brand-light-gray)' }}>
<HeaderBar onLoginClick={() => setShowLoginModal(true)} />
<HeroSection />
<AboutSection />
<FeaturesSection />
<HowItWorksSection />
<CommunitySection />
{/* CTA */}
<AnimatedSection backgroundColor='var(--panel-bg)'>
<div className='max-w-4xl mx-auto text-center'>
<motion.h2 className='text-5xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
AI
</motion.h2>
<motion.p className='text-xl mb-12' style={{ color: 'var(--text-secondary)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.1 }}>
TradFiNOFX AgentFi
</motion.p>
<div className='flex flex-wrap justify-center gap-4'>
<motion.button onClick={() => setShowLoginModal(true)} className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<motion.div animate={{ x: [0, 5, 0] }} transition={{ duration: 1.5, repeat: Infinity }}>
<ArrowRight className='w-5 h-5' />
</motion.div>
</motion.button>
<motion.a href='https://github.com/tinkle-community/nofx/tree/dev' target='_blank' rel='noopener noreferrer' className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
</motion.a>
</div>
</div>
</AnimatedSection>
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} />}
<FooterSection />
</div>
)
}

View File

@@ -84,23 +84,97 @@ export interface Statistics {
total_close_positions: number;
}
// 新增:竞赛相关类型
// AI Trading相关类型
export interface TraderInfo {
trader_id: string;
trader_name: string;
ai_model: string;
exchange_id?: string;
is_running?: boolean;
custom_prompt?: string;
}
export interface AIModel {
id: string;
name: string;
provider: string;
enabled: boolean;
apiKey?: string;
customApiUrl?: string;
customModelName?: string;
}
export interface Exchange {
id: string;
name: string;
type: 'cex' | 'dex';
enabled: boolean;
apiKey?: string;
secretKey?: string;
testnet?: boolean;
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string;
// Aster 特定字段
asterUser?: string;
asterSigner?: string;
asterPrivateKey?: string;
}
export interface CreateTraderRequest {
name: string;
ai_model_id: string;
exchange_id: string;
initial_balance: number;
btc_eth_leverage?: number;
altcoin_leverage?: number;
trading_symbols?: string;
custom_prompt?: string;
override_base_prompt?: boolean;
system_prompt_template?: string;
is_cross_margin?: boolean;
use_coin_pool?: boolean;
use_oi_top?: boolean;
}
export interface UpdateModelConfigRequest {
models: {
[key: string]: {
enabled: boolean;
api_key: string;
custom_api_url?: string;
custom_model_name?: string;
};
};
}
export interface UpdateExchangeConfigRequest {
exchanges: {
[key: string]: {
enabled: boolean;
api_key: string;
secret_key: string;
testnet?: boolean;
// Hyperliquid 特定字段
hyperliquid_wallet_addr?: string;
// Aster 特定字段
aster_user?: string;
aster_signer?: string;
aster_private_key?: string;
};
};
}
// Competition related types
export interface CompetitionTraderData {
trader_id: string;
trader_name: string;
ai_model: string;
exchange: string;
total_equity: number;
total_pnl: number;
total_pnl_pct: number;
position_count: number;
margin_used_pct: number;
call_count: number;
is_running: boolean;
}
@@ -108,3 +182,21 @@ export interface CompetitionData {
traders: CompetitionTraderData[];
count: number;
}
// Trader Configuration Data for View Modal
export interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
is_running: boolean;
}