feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权

This commit is contained in:
2026-08-14 22:37:18 +08:00
parent 0f8b9a7255
commit 4c2ea5521d
47 changed files with 2937 additions and 893 deletions

View File

@@ -1,11 +1,11 @@
<!doctype html>
<html lang="zh-CN" class="app-dark">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>股市回测平台</title>
<title>量化选股与回测平台</title>
<style>
html, body { background-color: #0b0e14; margin: 0; }
html, body { background-color: #f8fafc; margin: 0; }
</style>
</head>
<body>

View File

@@ -10,18 +10,17 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@primeuix/themes": "^1.0.0",
"echarts": "^6.0.0",
"lightweight-charts": "^5.0.0",
"klinecharts": "^10.0.2",
"pinia": "^2.3.0",
"primeicons": "^7.0.0",
"primevue": "^5.0.0",
"vue": "^3.5.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"

538
frontend/pnpm-lock.yaml generated
View File

@@ -8,24 +8,15 @@ importers:
.:
dependencies:
'@primeuix/themes':
specifier: ^1.0.0
version: 1.0.0
echarts:
specifier: ^6.0.0
version: 6.1.0
lightweight-charts:
specifier: ^5.0.0
version: 5.2.0
klinecharts:
specifier: ^10.0.2
version: 10.0.2
pinia:
specifier: ^2.3.0
version: 2.3.0(typescript@5.6.2)(vue@3.5.41(typescript@5.6.2))
primeicons:
specifier: ^7.0.0
version: 7.0.0
primevue:
specifier: ^5.0.0
version: 5.0.0(vue@3.5.41(typescript@5.6.2))
vue:
specifier: ^3.5.0
version: 3.5.41(typescript@5.6.2)
@@ -33,18 +24,24 @@ importers:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.41(typescript@5.6.2))
devDependencies:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
'@types/node':
specifier: ^22.0.0
version: 22.20.1
'@vitejs/plugin-vue':
specifier: ^5.2.0
version: 5.2.0(vite@6.4.3(@types/node@22.20.1))(vue@3.5.41(typescript@5.6.2))
version: 5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.41(typescript@5.6.2))
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
typescript:
specifier: ^5.6.0
version: 5.6.2
vite:
specifier: ^6.0.0
version: 6.4.3(@types/node@22.20.1)
version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vue-tsc:
specifier: ^2.1.0
version: 2.1.0(typescript@5.6.2)
@@ -218,9 +215,27 @@ packages:
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
@@ -228,60 +243,6 @@ packages:
os: [linux]
libc: [glibc]
'@noble/ed25519@2.3.0':
resolution: {integrity: sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@primeicons/core@8.0.0':
resolution: {integrity: sha512-vPif+sXxajXCSL2bmNBP0QYliJONVRgFVpCg9e7hGn7IG18JkMAm4fF5HVld1N4sDATkZQM7jKVxdZ8EK09Giw==}
'@primeicons/vue@8.0.0':
resolution: {integrity: sha512-sIqk+kZB9Bn0FqODjDAnNvRXY4Ypky2TInY/oIC3aAJOud1A3FoKruMC8IwiRDz1uHzxYnQqWwtFsfZAgC7qXQ==}
peerDependencies:
vue: ^3
'@primeui/license-manager@1.0.0':
resolution: {integrity: sha512-89J7r8cclEqoHVwjOX1adPcB/blFSocpzuYlzmd+6r0gxzg2Vd4jM54TKXt9/qwAT/kOv4vYnHKZ6hcP2GFtfA==}
'@primeuix/motion@1.0.0':
resolution: {integrity: sha512-rqpFRKmUVp9BI3YQKdok0rLUwzYShXzuuxy72Kq74xYRV14eR+4+XrIXOdyVs7j7CYV0ajYowiRpJCrVgdrq6A==}
engines: {node: '>=12.11.0'}
'@primeuix/styled@0.5.0':
resolution: {integrity: sha512-k5CTQ+10cXIXxZTep7sktmYe8lJkjmUaFVDAc1OCsWTJR+bhBy/s6zWIatGljVtuf3RmTSxtlrHQeFLjPmdUNQ==}
engines: {node: '>=12.11.0'}
'@primeuix/styled@1.0.0':
resolution: {integrity: sha512-6SXoeQWwKswSqTv1ygaXNfY7otHo6Rb9mxbKJK5WF8adWlZ6CtwmlNfIP+p8cH/zGccsei/5Bu7IetXlMoZGjQ==}
engines: {node: '>=12.11.0'}
'@primeuix/styles@3.0.0':
resolution: {integrity: sha512-nFsG2V0kbn3Q/fr0EV0Lxy2cKeW1F+WFXyxWI1CxWAAXW34mTCLkDd6OFbN5dP9hrSJOVaeXWzTdoonh1sdklQ==}
'@primeuix/themes@1.0.0':
resolution: {integrity: sha512-fxUgcAP9H6FeytbE8c4QvRt8aBnoyZJqvtnnVwHT8PHr1dNSnC1nYKGrXpebcx3SpNy9Hp9oVidGsl6u61+pXQ==}
'@primeuix/utils@0.5.0':
resolution: {integrity: sha512-LwbO4LBf3NM794ZKhI9XyPjnSIIaCGL+gcAsjNqZxalYAwtNO5E38duaBkBDUHh6/CnHab13ZshLur/xyc+KkQ==}
engines: {node: '>=12.11.0'}
'@primeuix/utils@0.8.0':
resolution: {integrity: sha512-dZSMKU2XJ7W7VDLuFMS/o4k+QYw4SYIpqSNhR/jdasMaaXl8eq0Gt7fTzTwwq7hgfmfOsI6V5xtzUUIvybOJ/w==}
engines: {node: '>=12.11.0'}
'@primevue/core@5.0.0':
resolution: {integrity: sha512-VGw1a5m3bSHosAQScg7huvD8ZFQ4cfltmbIuOSx7gsMS2NnYno9BCIq7O2L2H3zmB0pBLfcpdawb3EPeg1LIxw==}
engines: {node: '>=12.11.0'}
peerDependencies:
vue: ^3.5.0
'@primevue/icons@5.0.0':
resolution: {integrity: sha512-DyrtQhmLRiIkldjm5jmuqiFeNN05n4VWd1sw4+WfJ0Zlb+5T4YASw6CGtCITPzHVYyx6XuuEMifruzc8OVpQrw==}
engines: {node: '>=12.11.0'}
'@rollup/rollup-android-arm-eabi@4.62.4':
resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
cpu: [arm]
@@ -420,6 +381,100 @@ packages:
cpu: [x64]
os: [win32]
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/oxide-android-arm64@4.3.3':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.3.3':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.3.3':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.3.3':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.3.3':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.3.3':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -501,9 +556,17 @@ packages:
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
enhanced-resolve@5.24.5:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -516,9 +579,6 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fancy-canvas@2.1.0:
resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -533,12 +593,93 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
lightweight-charts@5.2.0:
resolution: {integrity: sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
klinecharts@10.0.2:
resolution: {integrity: sha512-OJmaG047vd6RPKXxQAnbGPnfUQ3tKCKBlxk/oMvRJBXO6CyaZuqFGQ8ccoU6YTNew7DV8ynbsfti/JERe+M5IQ==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -578,13 +719,6 @@ packages:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
primeicons@7.0.0:
resolution: {integrity: sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==}
primevue@5.0.0:
resolution: {integrity: sha512-o0MtP6Dxa5QFZuskBWoiLD7aM/V6emqqJJnd+L2nNSfH4PrQsNpqSVPZODSdNJfKXAD9ekjTAJ+QyHycKCmF3Q==}
engines: {node: '>=12.11.0'}
rollup@4.62.4:
resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -599,6 +733,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -780,69 +921,31 @@ snapshots:
'@esbuild/win32-x64@0.25.0':
optional: true
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@noble/ed25519@2.3.0': {}
'@noble/hashes@2.2.0': {}
'@primeicons/core@8.0.0':
dependencies:
'@primeuix/utils': 0.8.0
'@primeicons/vue@8.0.0(vue@3.5.41(typescript@5.6.2))':
dependencies:
'@primeicons/core': 8.0.0
'@primeuix/utils': 0.8.0
vue: 3.5.41(typescript@5.6.2)
'@primeui/license-manager@1.0.0':
dependencies:
'@noble/ed25519': 2.3.0
'@noble/hashes': 2.2.0
'@primeuix/motion@1.0.0':
dependencies:
'@primeuix/utils': 0.8.0
'@primeuix/styled@0.5.0':
dependencies:
'@primeuix/utils': 0.5.0
'@primeuix/styled@1.0.0':
dependencies:
'@primeui/license-manager': 1.0.0
'@primeuix/utils': 0.8.0
'@primeuix/styles@3.0.0':
dependencies:
'@primeuix/styled': 1.0.0
'@primeuix/themes@1.0.0':
dependencies:
'@primeuix/styled': 0.5.0
'@primeuix/utils@0.5.0': {}
'@primeuix/utils@0.8.0': {}
'@primevue/core@5.0.0(vue@3.5.41(typescript@5.6.2))':
dependencies:
'@primeui/license-manager': 1.0.0
'@primeuix/styled': 1.0.0
'@primeuix/utils': 0.8.0
vue: 3.5.41(typescript@5.6.2)
'@primevue/icons@5.0.0(vue@3.5.41(typescript@5.6.2))':
dependencies:
'@primeuix/utils': 0.8.0
'@primevue/core': 5.0.0(vue@3.5.41(typescript@5.6.2))
transitivePeerDependencies:
- vue
'@rollup/rollup-android-arm-eabi@4.62.4':
optional: true
@@ -918,15 +1021,83 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.4':
optional: true
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.5
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.3
'@tailwindcss/oxide-android-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-x64@4.3.3':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide@4.3.3':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
'@types/estree@1.0.9': {}
'@types/node@22.20.1':
dependencies:
undici-types: 6.21.0
'@vitejs/plugin-vue@5.2.0(vite@6.4.3(@types/node@22.20.1))(vue@3.5.41(typescript@5.6.2))':
'@vitejs/plugin-vue@5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.41(typescript@5.6.2))':
dependencies:
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vue: 3.5.41(typescript@5.6.2)
'@volar/language-core@2.4.28':
@@ -1029,11 +1200,18 @@ snapshots:
de-indent@1.0.2: {}
detect-libc@2.1.2: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
enhanced-resolve@5.24.5:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@7.0.1: {}
esbuild@0.25.0:
@@ -1066,8 +1244,6 @@ snapshots:
estree-walker@2.0.2: {}
fancy-canvas@2.1.0: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -1075,11 +1251,62 @@ snapshots:
fsevents@2.3.3:
optional: true
graceful-fs@4.2.11: {}
he@1.2.0: {}
lightweight-charts@5.2.0:
jiti@2.7.0: {}
klinecharts@10.0.2: {}
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.32.0:
dependencies:
fancy-canvas: 2.1.0
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
magic-string@0.30.21:
dependencies:
@@ -1115,21 +1342,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
primeicons@7.0.0: {}
primevue@5.0.0(vue@3.5.41(typescript@5.6.2)):
dependencies:
'@primeicons/vue': 8.0.0(vue@3.5.41(typescript@5.6.2))
'@primeui/license-manager': 1.0.0
'@primeuix/motion': 1.0.0
'@primeuix/styled': 1.0.0
'@primeuix/styles': 3.0.0
'@primeuix/utils': 0.8.0
'@primevue/core': 5.0.0(vue@3.5.41(typescript@5.6.2))
'@primevue/icons': 5.0.0(vue@3.5.41(typescript@5.6.2))
transitivePeerDependencies:
- vue
rollup@4.62.4:
dependencies:
'@types/estree': 1.0.9
@@ -1166,6 +1378,10 @@ snapshots:
source-map-js@1.2.1: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
@@ -1177,7 +1393,7 @@ snapshots:
undici-types@6.21.0: {}
vite@6.4.3(@types/node@22.20.1):
vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.25.0
fdir: 6.5.0(picomatch@4.0.5)
@@ -1188,6 +1404,8 @@ snapshots:
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
jiti: 2.7.0
lightningcss: 1.32.0
vscode-uri@3.1.0: {}

View File

@@ -1,18 +1,45 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
import { computed, ref } from 'vue';
import { RouterView } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isHome = computed(() => route.name === 'home');
const isLogin = computed(() => route.name === 'login');
const loggingOut = ref(false);
async function signOut() {
loggingOut.value = true;
try {
await auth.logout();
await router.replace({ name: 'login' });
} finally {
loggingOut.value = false;
}
}
</script>
<template>
<div class="app-shell">
<header class="app-header">
<h1>股市量化平台</h1>
<span class="sub">智能选股 · 历史回测 · A股红涨绿跌</span>
<nav class="app-nav">
<RouterLink to="/" class="nav-link">首页</RouterLink>
<RouterLink to="/screener" class="nav-link">智能选股</RouterLink>
<RouterLink to="/backtest" class="nav-link">策略回测</RouterLink>
</nav>
</header>
<RouterView />
<div v-if="!auth.initialized" class="grid min-h-screen place-items-center bg-slate-50" aria-label="正在验证登录状态">
<span class="login-spinner border-slate-300 border-t-blue-600" aria-hidden="true" />
</div>
<RouterView v-else-if="isLogin" />
<div v-else class="min-h-screen">
<div class="fixed right-5 top-4 z-30 flex items-center gap-3 rounded-md border border-slate-200 bg-white/90 px-3 py-2 text-xs text-slate-400 shadow-sm backdrop-blur">
<span>{{ auth.user?.username }}</span>
<button type="button" class="font-medium text-slate-500 hover:text-slate-900 disabled:opacity-50" :disabled="loggingOut" @click="signOut">
{{ loggingOut ? '退出中' : '退出' }}
</button>
</div>
<main :class="isHome ? 'flex min-h-screen items-center justify-center px-5 py-10' : 'mx-auto max-w-[1400px] px-5 py-6'">
<RouterView />
</main>
<footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-xs text-slate-400">
数据来源 Tushare · 仅供研究学习不构成投资建议
</footer>
</div>
</template>

View File

@@ -1,6 +1,10 @@
import type {
BacktestRequest,
BacktestResponse,
CurrentUser,
LoginRequest,
LoginResponse,
PreviewResponse,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
@@ -12,71 +16,91 @@ import type {
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
const BASE = import.meta.env.VITE_API_BASE ?? '';
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
const res = await fetch(`${BASE}/api/backtest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`);
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
async function readError(res: Response, fallback: string): Promise<string> {
const body = await res.text();
if (!body) return fallback;
try {
const data = JSON.parse(body) as { detail?: string };
return data.detail || fallback;
} catch {
return body;
}
}
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include',
headers: {
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
if (res.status === 401 && !path.startsWith('/api/auth/login')) {
window.dispatchEvent(new CustomEvent('stock:unauthorized'));
}
return res;
}
export async function login(req: LoginRequest): Promise<LoginResponse> {
const res = await apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, '登录失败'), res.status);
return (await res.json()) as LoginResponse;
}
export async function getCurrentUser(): Promise<CurrentUser> {
const res = await apiFetch('/api/auth/me');
if (!res.ok) throw new ApiError(await readError(res, '登录状态无效'), res.status);
return (await res.json()) as CurrentUser;
}
export async function logout(): Promise<void> {
const res = await apiFetch('/api/auth/logout', { method: 'POST' });
if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status);
}
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
const res = await apiFetch('/api/backtest', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
return (await res.json()) as BacktestResponse;
}
export async function syncData(req: SyncRequest): Promise<SyncResponse> {
const res = await fetch(`${BASE}/api/data/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`);
}
const res = await apiFetch('/api/data/sync', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
return (await res.json()) as SyncResponse;
}
// ---------- 智能选股 ----------
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
const res = await fetch(`${BASE}/api/screener/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
// 后端 detail 字段携带可读中文原因503 未配 key / 409 未同步 / 502 LLM 错)
let detail = '';
try {
detail = (await res.json())?.detail ?? '';
} catch {
detail = await res.text();
}
throw new Error(detail || `选股失败 (HTTP ${res.status})`);
}
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ScreenerRunResponse;
}
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
const res = await fetch(`${BASE}/api/screener/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
let detail = '';
try {
detail = (await res.json())?.detail ?? '';
} catch {
detail = await res.text();
}
throw new Error(detail || `启动同步失败 (HTTP ${res.status})`);
}
const res = await apiFetch('/api/screener/sync', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, `启动同步失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ScreenerSyncStatus;
}
export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
const res = await fetch(`${BASE}/api/screener/sync/status`);
if (!res.ok) throw new Error(`获取同步状态失败 (HTTP ${res.status})`);
const res = await apiFetch('/api/screener/sync/status');
if (!res.ok) throw new ApiError(`获取同步状态失败 (HTTP ${res.status})`, res.status);
return (await res.json()) as ScreenerSyncStatus;
}
export async function getStockPreview(tsCode: string, limit = 260): Promise<PreviewResponse> {
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?limit=${limit}`);
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as PreviewResponse;
}

View File

@@ -1,6 +1,21 @@
// 与后端 app/schemas.py 一一对应的 TypeScript 类型OpenAPI 契约的前端镜像)。
// 后续可由 openapi-typescript-codegen 自动生成MVP 先手写保持同步。
export interface CurrentUser {
id: number;
username: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
user: CurrentUser;
expires_at: string;
}
export interface Candle {
ts: string;
open: number;
@@ -147,3 +162,37 @@ export interface ScreenerSyncStatus {
last_synced_at?: string | null;
stats: { stocks: number; daily_rows: number; snapshot_rows: number; dates: number };
}
// ---------- 个股详情预览(镜像 app/schemas.py ----------
export interface PreviewInfo {
ts_code: string;
symbol: string;
name: string;
industry?: string | null;
area?: string | null;
market?: string | null;
list_date?: string | null;
trade_date?: string | null;
open?: number | null;
high?: number | null;
low?: number | null;
close?: number | null;
pre_close?: number | null;
pct_chg?: number | null;
volume_hand?: number | null; // 手
amount_yi?: number | null; // 亿元
turnover_rate?: number | null; // %
pe_ttm?: number | null;
pb?: number | null;
total_mv?: number | null; // 亿元
circ_mv?: number | null; // 亿元
}
export interface PreviewResponse {
ts_code: string;
symbol: string;
source: string; // qfq | market
info: PreviewInfo;
candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>;
}

View File

@@ -1,10 +1,5 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import type { BacktestRequest } from '@/api/types';
defineProps<{ loading: boolean }>();
@@ -16,7 +11,19 @@ const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
];
const stratOptions = STRATS.map((s) => ({ label: s.label, value: s.id }));
const TF_OPTIONS = [
{ label: '日线', value: '1d' },
{ label: '周线', value: '1w' },
{ label: '月线', value: '1M' },
{ label: '年线', value: '1y' },
];
const QUICK = [
{ code: '000001', name: '平安银行' },
{ code: '600519', name: '贵州茅台' },
{ code: '000858', name: '五粮液' },
{ code: '601318', name: '中国平安' },
{ code: 'DEMO', name: '合成数据' },
];
const form = reactive({
symbol: '000001',
@@ -49,42 +56,68 @@ function onRun() {
</script>
<template>
<div class="toolbar">
<div class="field">
<label>策略</label>
<Select v-model="form.strategy" :options="stratOptions" optionLabel="label" optionValue="value" size="small" style="width: 170px" />
</div>
<div class="field" v-for="p in currentParams" :key="p.k">
<label>{{ p.label }}</label>
<InputNumber v-model="form.params[p.k]" :min="1" :max="250" size="small" inputStyle="width:64px" />
</div>
<div class="field">
<label>周期</label>
<Select v-model="form.timeframe" :options="[{label:'日线',value:'1d'},{label:'周线',value:'1w'},{label:'月线',value:'1M'},{label:'年线',value:'1y'}]" optionLabel="label" optionValue="value" size="small" style="width: 100px" />
</div>
<div class="field">
<label>标的</label>
<InputText v-model="form.symbol" size="small" style="width: 110px" placeholder="如 000001" />
</div>
<div class="field">
<label>初始资金</label>
<InputNumber v-model="form.initial_cash" :min="1000" :step="100000" size="small" mode="currency" currency="CNY" inputStyle="width:130px" />
</div>
<div class="field">
<label>fast 模式</label>
<ToggleSwitch v-model="form.fast_mode" />
</div>
<div class="spacer"></div>
<Button label="开始回测" icon="pi pi-play" :loading="loading" size="small" @click="onRun" />
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
<div>
<label class="lbl">策略</label>
<select v-model="form.strategy" class="ipt w-40">
<option v-for="s in STRATS" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</div>
<div class="quick">
<span class="qlabel">快捷</span>
<button v-for="q in [{code:'000001',name:'平安银行'},{code:'600519',name:'贵州茅台'},{code:'000858',name:'五粮液'},{code:'601318',name:'中国平安'},{code:'DEMO',name:'合成数据'}]" :key="q.code" class="qchip" :class="{ active: form.symbol === q.code }" type="button" @click="form.symbol = q.code">
{{ q.code }} <span class="qname">{{ q.name }}</span>
</button>
</div>
<div class="hint">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code>DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
<div v-for="p in currentParams" :key="p.k">
<label class="lbl">{{ p.label }}</label>
<input v-model.number="form.params[p.k]" type="number" min="1" max="250" class="ipt w-[76px]" />
</div>
<div>
<label class="lbl">周期</label>
<select v-model="form.timeframe" class="ipt w-24">
<option v-for="t in TF_OPTIONS" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div>
<label class="lbl">标的</label>
<input v-model="form.symbol" type="text" class="ipt w-[110px]" placeholder="如 000001" />
</div>
<div>
<label class="lbl">初始资金</label>
<input v-model.number="form.initial_cash" type="number" min="1000" step="100000" class="ipt w-[140px]" />
</div>
<label class="flex cursor-pointer select-none items-center gap-2 pb-1.5 text-sm text-slate-600">
<input v-model="form.fast_mode" type="checkbox" class="h-4 w-4 rounded border-slate-300 accent-blue-600" />
fast 模式
</label>
<div class="ml-auto">
<button type="button" class="btn-primary" :disabled="loading" @click="onRun">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">快捷</span>
<button
v-for="q in QUICK"
:key="q.code"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="form.symbol === q.code
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100'"
@click="form.symbol = q.code"
>
{{ q.code }} <span :class="form.symbol === q.code ? 'text-blue-200' : 'text-slate-400'">{{ q.name }}</span>
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code class="rounded border border-slate-200 bg-slate-50 px-1">DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
</p>
</div>
</template>

View File

@@ -5,14 +5,13 @@ defineProps<{ conditions: ScreenConditions }>();
const OP_TEXT: Record<string, string> = { gt: '>', ge: '≥', lt: '<', le: '≤', between: '区间' };
const FIELD_TEXT: Record<string, string> = {
total_mv: '总市值', circ_mv: '流通市值', pe_ttm: '市盈率TTM',
pb: '市净率', turnover_rate: '换手率', close: '最新价',
total_mv: '总市值(亿)', circ_mv: '流通市值(亿)', pe_ttm: '市盈率TTM',
pb: '市净率', turnover_rate: '换手率%', close: '最新价',
};
function paramsStr(p?: Record<string, number>) {
if (!p || Object.keys(p).length === 0) return '';
const vals = Object.values(p).map((v) => (Number.isInteger(v) ? String(v) : String(v)));
return `(${vals.join(',')})`;
return `(${Object.values(p).map((v) => (Number.isInteger(v) ? v : v)).join(',')})`;
}
function lookbackText(c: { lookback?: number; match?: string }) {
@@ -23,28 +22,36 @@ function lookbackText(c: { lookback?: number; match?: string }) {
</script>
<template>
<div class="cond-chips">
<span class="clabel">解析条件</span>
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">解析条件</span>
<span v-for="(c, i) in conditions.indicator" :key="'i' + i" class="cond-chip ci">
<i class="pi pi-bolt" style="font-size: 10px;"></i>
<span
v-for="(c, i) in conditions.indicator"
:key="'i' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs text-blue-900"
>
<span class="h-1.5 w-1.5 rounded-full bg-blue-500"></span>
{{ c.indicator }}{{ paramsStr(c.params) }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<span class="text-blue-400">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.value_indicator">{{ c.value_indicator }}{{ paramsStr(c.value_params) }}</template>
<template v-else-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
<span style="color: var(--ink-3);"> · {{ lookbackText(c) }}</span>
<span class="text-blue-300">· {{ lookbackText(c) }}</span>
</span>
<span v-for="(c, i) in conditions.snapshot" :key="'s' + i" class="cond-chip cs">
<i class="pi pi-table" style="font-size: 10px;"></i>
<span
v-for="(c, i) in conditions.snapshot"
:key="'s' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs text-amber-900"
>
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
{{ FIELD_TEXT[c.field] ?? c.field }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<span class="text-amber-400">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
</span>
<span class="cond-chip cx">
<span class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-400">
排除ST · 退市<span v-if="conditions.exclude_bj"> · 北交所</span>
</span>
</div>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
import type { Candle } from '@/api/types';
const props = defineProps<{
ticker: string;
candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[];
/** 主图是否叠加布林带 */
showBoll: boolean;
}>();
// A股语义色浅色
const UP = '#dc2626';
const DOWN = '#16a34a';
const C1 = '#2563eb'; // 蓝
const C2 = '#f59e0b'; // 橙
const C3 = '#a855f7'; // 紫
const C4 = '#10b981'; // 绿青
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
let PV: Record<string, (number | null)[]> = {};
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
registerIndicator({
name: 'pv-ma',
shortName: 'MA',
figures: [
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
});
registerIndicator({
name: 'pv-boll',
shortName: 'BOLL',
figures: [
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
});
registerIndicator({
name: 'pv-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (d: KLineData[]) => d.map((_, i) => ({ dif: g('dif')(i), dea: g('dea')(i), hist: g('hist')(i) })),
});
registerIndicator({
name: 'pv-kdj',
shortName: 'KDJ',
figures: [
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
});
registerIndicator({
name: 'pv-rsi',
shortName: 'RSI',
figures: [
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
});
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } },
vertical: { text: { backgroundColor: '#1e293b' } },
},
separator: { color: '#e2e8f0' },
};
// 副图默认高度
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
function build() {
if (!container.value || props.candles.length === 0) return;
PV = {};
for (const [group, series] of Object.entries(props.indicators)) {
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
}
const ch = init(container.value, { styles: LIGHT_STYLES });
if (!ch) return;
chart = ch;
const data: KLineData[] = props.candles.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = data[data.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
} else if (type === 'init') {
callback(data, { backward: false, forward: false });
} else {
callback([], { backward: false, forward: false });
}
},
});
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一则 getBars 永不调用、图表空白
ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 });
// 主图MA 恒开BOLL 可选
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并压矮;主图吃剩余高度
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
for (const key of props.subPanes) {
if (key === 'vol') ch.createIndicator('VOL');
else ch.createIndicator(`pv-${key}`);
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
}
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
}
function teardown() {
if (container.value) dispose(container.value);
chart = null;
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
</script>
<template>
<div ref="container" class="h-full w-full"></div>
</template>

View File

@@ -8,38 +8,41 @@ const props = defineProps<{ equity: EquityPoint[] }>();
const container = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
const UP = '#dc2626';
const DOWN = '#16a34a';
function buildOption() {
const dates = props.equity.map(p => p.ts.slice(0, 10));
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
const first = vals.length ? vals[0] : 0;
const last = vals.length ? vals[vals.length - 1] : 0;
const lineColor = last >= first ? '#f6465d' : '#0ecb81'; // A股盈利红、亏损绿
const lineColor = last >= first ? UP : DOWN; // A股盈利红、亏损绿
return {
backgroundColor: 'transparent',
grid: { left: 64, right: 18, top: 14, bottom: 26 },
tooltip: {
trigger: 'axis' as const,
backgroundColor: '#1b2230', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
textStyle: { color: '#e6edf3' },
backgroundColor: '#ffffff', borderColor: '#e2e8f0', borderWidth: 1,
textStyle: { color: '#0f172a' },
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
},
xAxis: {
type: 'category', data: dates, boundaryGap: false,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
axisLabel: { color: '#5c6675' }, axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8' }, axisTick: { show: false },
},
yAxis: {
type: 'value', scale: true,
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } },
axisLabel: { color: '#5c6675' },
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#94a3b8' },
},
series: [{
type: 'line', data: vals, symbol: 'none', smooth: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '40' },
{ offset: 0, color: lineColor + '33' },
{ offset: 1, color: lineColor + '00' },
]),
},
@@ -56,5 +59,5 @@ watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: t
</script>
<template>
<div ref="container" class="chart-equity"></div>
<div ref="container" class="h-[240px] w-full"></div>
</template>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
createChart, CandlestickSeries, HistogramSeries, LineSeries,
createSeriesMarkers, CrosshairMode, LineStyle,
type IChartApi, type ISeriesApi, type ISeriesMarkersPluginApi, type SeriesMarker, type Time,
} from 'lightweight-charts';
import { dispose, init, registerIndicator, type Chart, type Crosshair, type KLineData } from 'klinecharts';
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
const props = defineProps<{
@@ -20,70 +16,114 @@ const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M':
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const UP = '#f6465d';
const DOWN = '#0ecb81';
const DIF = '#5b8ff9';
const DEA = '#f6bd16';
const MA_COLORS = ['#5b8ff9', '#f6bd16', '#c084fc', '#34d399'];
// A股语义色浅色主题
const UP = '#dc2626';
const DOWN = '#16a34a';
const DIF_C = '#2563eb';
const DEA_C = '#f59e0b';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981'];
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
const IND_COLOR: Record<string, string> = {
macd: DIF, signal: DEA, hist: '#9aa4b2', fast: DIF, slow: DEA, ma: '#c084fc',
macd: DIF_C, signal: DEA_C, hist: '#94a3b8', fast: DIF_C, slow: DEA_C, ma: '#a855f7',
};
const container = ref<HTMLDivElement | null>(null);
let chart: IChartApi | null = null;
let candleSeries: ISeriesApi<'Candlestick'> | null = null;
let volumeSeries: ISeriesApi<'Histogram'> | null = null;
let difSeries: ISeriesApi<'Line'> | null = null;
let deaSeries: ISeriesApi<'Line'> | null = null;
let histSeries: ISeriesApi<'Histogram'> | null = null;
let maSeriesArr: { key: string; series: ISeriesApi<'Line'> }[] = [];
let markersApi: ISeriesMarkersPluginApi<Time> | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byTime: Record<string, DayRec> = {};
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
const legendChips = computed(() => {
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value) return [{ label: 'DIF', color: DIF }, { label: 'DEA', color: DEA }];
if (isMACD.value) return [{ label: 'DIF', color: DIF_C }, { label: 'DEA', color: DEA_C }];
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
});
const t = (ts: string): Time => ts.slice(0, 10) as Time;
function timeKey(time: Time): string {
if (typeof time === 'string') return time.slice(0, 10);
const bd = time as { year: number; month: number; day: number };
if (bd && typeof bd === 'object' && 'year' in bd) {
return `${bd.year}-${String(bd.month).padStart(2, '0')}-${String(bd.day).padStart(2, '0')}`;
}
return String(time);
// ---------- 后端指标数据注入(单一事实源:不在前端重算指标) ----------
// calc 回调按索引回读这些序列,与 K 线严格对齐
let BE_SERIES: Record<string, (number | null)[]> = {};
registerIndicator({
name: 'be-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: DEA_C }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
dif: BE_SERIES.macd?.[i] ?? undefined,
dea: BE_SERIES.signal?.[i] ?? undefined,
hist: BE_SERIES.hist?.[i] ?? undefined,
})),
});
registerIndicator({
name: 'be-lines',
shortName: 'MA',
figures: [
{ key: 'fast', title: '快线', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'slow', title: '慢线', type: 'line', styles: () => ({ color: DEA_C }) },
{ key: 'ma', title: '均线', type: 'line', styles: () => ({ color: MA_COLORS[2] }) },
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
fast: BE_SERIES.fast?.[i] ?? undefined,
slow: BE_SERIES.slow?.[i] ?? undefined,
ma: BE_SERIES.ma?.[i] ?? undefined,
})),
});
// ---------- 画线工具 ----------
const TOOLS: { name: string | null; label: string; title: string }[] = [
{ name: null, label: '指针', title: '浏览模式(点击已画图形可选中/拖动/编辑)' },
{ name: 'segment', label: '线段', title: '线段' },
{ name: 'horizontalStraightLine', label: '水平线', title: '水平直线' },
{ name: 'verticalStraightLine', label: '垂直线', title: '垂直直线' },
{ name: 'rectangle', label: '矩形', title: '矩形区域' },
{ name: 'fibonacciSegment', label: '斐波那契', title: '斐波那契回调' },
{ name: 'priceChannelLine', label: '通道线', title: '价格通道线' },
];
const activeTool = ref<string | null>(null);
function pickTool(name: string) {
activeTool.value = name;
chart?.createOverlay({ name });
}
function clearDrawings() {
if (!chart) return;
chart.removeOverlay(); // 清除全部(含买卖点标注),随后重建标注
drawSignalAnnotations();
}
// ---------- 图表 ----------
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byIndex: DayRec[] = [];
let candleData: KLineData[] = [];
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
function hexA(hex: string, a: number) {
const n = hex.replace('#', '');
return `rgba(${parseInt(n.slice(0, 2), 16)},${parseInt(n.slice(2, 4), 16)},${parseInt(n.slice(4, 6), 16)},${a})`;
}
function toLine(arr: (number | null)[], c: Candle[]) {
return arr.map((v, i) => (v == null ? { time: t(c[i].ts) } : { time: t(c[i].ts), value: v }));
}
function buildTip(rec: DayRec, key: string) {
function buildTip(rec: DayRec, ts: string) {
const prev = rec.prevClose ?? rec.open;
const change = rec.close - prev;
return {
date: key, weekday: weekdayOf(key),
date: ts.slice(0, 10), weekday: weekdayOf(ts.slice(0, 10)),
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
change, chgPct: prev ? (change / prev) * 100 : 0,
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
@@ -92,111 +132,127 @@ function buildTip(rec: DayRec, key: string) {
};
}
function build() {
if (!container.value) return;
const ch = createChart(container.value, {
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: '#9aa4b2', fontSize: 11, attributionLogo: false },
grid: { vertLines: { color: 'rgba(255,255,255,0.04)' }, horzLines: { color: 'rgba(255,255,255,0.04)' } },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
horzLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
// 浅色主题 + A股红涨绿跌与默认样式深合并
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.08, bottom: 0.28 } },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', rightOffset: 6, barSpacing: 8 },
});
priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } },
vertical: { text: { backgroundColor: '#1e293b' } },
},
separator: { color: '#e2e8f0' },
};
function build() {
if (!container.value || props.candles.length === 0) return;
BE_SERIES = props.indicators.data ?? {};
const ch = init(container.value, { styles: LIGHT_STYLES });
if (!ch) return;
chart = ch;
candleSeries = ch.addSeries(CandlestickSeries, {
upColor: UP, downColor: DOWN, borderUpColor: UP, borderDownColor: DOWN, wickUpColor: UP, wickDownColor: DOWN,
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
}, 0);
volumeSeries = ch.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'vol' }, 0);
volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value && props.indicators.data?.hist) {
// MACD 进副图(震荡指标,独立刻度)
difSeries = ch.addSeries(LineSeries, { color: DIF, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
deaSeries = ch.addSeries(LineSeries, { color: DEA, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
histSeries = ch.addSeries(HistogramSeries, { priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: false }, 1);
try { ch.panes()[1]?.setHeight(140); } catch { /* pane 未就绪 */ }
} else {
// 均线叠加在主图(价格刻度,与 K 线同坐标系)
maSeriesArr = keys.map((k, i) => ({
key: k,
series: ch.addSeries(LineSeries, {
color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false,
lastValueVisible: false, crosshairMarkerVisible: true,
}, 0),
}));
}
markersApi = createSeriesMarkers(candleSeries, []);
ch.subscribeCrosshairMove((param) => {
const pt = param.point;
if (!param.time || !pt || !container.value) { tip.value.visible = false; return; }
const key = timeKey(param.time);
const rec = byTime[key];
if (!rec) { tip.value.visible = false; return; }
tipData.value = buildTip(rec, key);
const W = container.value.clientWidth, H = container.value.clientHeight;
const TW = 220, TH = 188;
let x = pt.x + 16; if (x + TW > W) x = pt.x - TW - 16; if (x < 4) x = 4;
let y = pt.y + 16; if (y + TH > H) y = H - TH - 6; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
});
fillData();
ch.timeScale().fitContent();
}
function fillData() {
if (!chart || !candleSeries) return;
// 逐日记录(悬停详情)
const c = props.candles;
const keys = Object.keys(props.indicators.data ?? {});
byTime = {};
c.forEach((k, i) => {
const keys = Object.keys(BE_SERIES);
byIndex = c.map((k, i) => {
const ind: Record<string, number | null> = {};
keys.forEach((key) => { ind[key] = props.indicators.data[key]?.[i] ?? null; });
byTime[t(k.ts) as unknown as string] = {
keys.forEach((key) => { ind[key] = BE_SERIES[key]?.[i] ?? null; });
return {
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
prevClose: i > 0 ? c[i - 1].close : null, ind,
};
});
candleData = c.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
const tsList = c.map((k) => k.ts);
candleSeries.setData(c.map(k => ({ time: t(k.ts), open: k.open, high: k.high, low: k.low, close: k.close })));
volumeSeries?.setData(c.map(k => ({
time: t(k.ts), value: k.volume, color: k.close >= k.open ? hexA(UP, 0.5) : hexA(DOWN, 0.5),
})));
// v10 数据接入DataLoader 一次性提供全量(回测结果静态数据,无分页)
// 注意v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一图表空白
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = candleData[candleData.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
} else if (type === 'init') {
callback(candleData, { backward: false, forward: false });
} else {
callback([], { backward: false, forward: false });
}
},
});
ch.setSymbol({ ticker: props.symbol ?? 'BACKTEST' });
ch.setPeriod({ type: 'day', span: 1 });
if (difSeries && deaSeries && histSeries && props.indicators.data?.hist) {
difSeries.setData(toLine(props.indicators.data.macd ?? [], c));
deaSeries.setData(toLine(props.indicators.data.signal ?? [], c));
histSeries.setData((props.indicators.data.hist ?? []).map((v, i) => ({
time: t(c[i].ts), value: v ?? 0, color: (v ?? 0) >= 0 ? hexA(UP, 0.6) : hexA(DOWN, 0.6),
})));
// 副图/叠加MACD 独立 pane均线叠加主图
ch.createIndicator('VOL');
if (isMACD.value) {
ch.createIndicator('be-macd');
} else {
maSeriesArr.forEach((m) => m.series.setData(toLine(props.indicators.data[m.key] ?? [], c)));
ch.createIndicator({ name: 'be-lines', paneId: 'candle_pane' });
}
// 副图压矮,主图占大头
for (const ind of ch.getIndicators()) {
if (ind.name === 'VOL') ch.setPaneOptions({ id: ind.paneId, height: 84 });
if (ind.name === 'be-macd') ch.setPaneOptions({ id: ind.paneId, height: 120 });
}
const markers: SeriesMarker<Time>[] = props.signals.map(s => ({
time: t(s.ts),
position: s.side === 'buy' ? 'belowBar' : 'aboveBar',
color: s.side === 'buy' ? UP : DOWN,
shape: s.side === 'buy' ? 'arrowUp' : 'arrowDown',
text: s.side === 'buy' ? 'B' : 'S',
}));
markersApi?.setMarkers(markers);
drawSignalAnnotations();
// 悬停详情crosshair 事件自带数据索引与像素坐标
ch.subscribeAction('onCrosshairChange', (d) => {
const data = d as Crosshair | undefined;
const i = data?.dataIndex;
if (data == null || i == null || i < 0 || i >= byIndex.length) {
tip.value.visible = false;
return;
}
tipData.value = buildTip(byIndex[i], tsList[i]);
const el = container.value;
if (el && data.x != null && data.y != null) {
const TW = 224, TH = 196;
let x = data.x + 16; if (x + TW > el.clientWidth) x = data.x - TW - 16; if (x < 4) x = 4;
let y = data.y + 16; if (y + TH > el.clientHeight) y = data.y - TH - 24; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
}
});
}
function drawSignalAnnotations() {
if (!chart) return;
const c = props.candles;
const idxOfTs = new Map<number, number>();
c.forEach((k, i) => idxOfTs.set(new Date(k.ts).getTime(), i));
for (const s of props.signals) {
const i = idxOfTs.get(new Date(s.ts).getTime());
if (i == null) continue;
const k = c[i];
const buy = s.side === 'buy';
chart.createOverlay({
name: 'simpleAnnotation',
points: [{ dataIndex: i, value: buy ? k.low : k.high }],
extendData: buy ? 'B' : 'S',
styles: { text: { color: buy ? UP : DOWN, size: 11, weight: 'bold' } },
});
}
}
function teardown() {
chart?.remove();
chart = null; candleSeries = null; volumeSeries = null;
difSeries = deaSeries = histSeries = null; maSeriesArr = []; markersApi = null;
if (container.value) dispose(container.value);
chart = null;
}
onMounted(build);
@@ -205,33 +261,59 @@ watch(() => [props.candles, props.indicators, props.signals, props.strategy], ()
</script>
<template>
<div class="kline-wrap">
<div class="lc-legend">
<span class="sym">{{ symbol ?? '—' }}<small>{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}</small></span>
<span v-for="(chip, i) in legendChips" :key="i" class="chip"><i :style="{ background: chip.color }"></i>{{ chip.label }}</span>
<div class="relative">
<!-- 图例 + 画线工具栏 -->
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-2">
<span class="text-[13px] font-semibold text-slate-900">
{{ symbol ?? '—' }}
<small class="ml-1.5 font-normal text-slate-400">
{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}
</small>
</span>
<span v-for="(chip, i) in legendChips" :key="i" class="flex items-center gap-1 text-xs text-slate-500">
<i class="inline-block h-0.5 w-3 rounded" :style="{ background: chip.color }"></i>{{ chip.label }}
</span>
<span class="ml-auto flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5">
<span class="px-1.5 text-[10px] text-slate-400">画线</span>
<button
v-for="tool in TOOLS"
:key="tool.label"
type="button"
:title="tool.title"
class="rounded-md px-2 py-1 text-xs transition-colors"
:class="activeTool === tool.name ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-white hover:shadow-sm'"
@click="tool.name === null ? (activeTool = null) : pickTool(tool.name)"
>{{ tool.label }}</button>
<button type="button" class="rounded-md px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-white hover:text-red-600" title="清除所有画线与标注" @click="clearDrawings">清除</button>
</span>
</div>
<div v-if="tip.visible && tipData" class="lc-tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
<div class="tt-date">{{ tipData.date }} <span class="tt-wd">{{ tipData.weekday }}</span></div>
<div class="tt-grid">
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="pos">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="neg">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.close) }}</b></div>
<!-- 悬停详情同花顺式 -->
<div
v-if="tip.visible && tipData"
class="pointer-events-none absolute z-20 w-[224px] rounded-lg border border-slate-200 bg-white/95 px-3 py-2 text-[11.5px] leading-relaxed text-slate-600 shadow-lg"
:style="{ left: tip.x + 'px', top: tip.y + 'px' }"
>
<div class="mb-0.5 font-semibold text-slate-900">{{ tipData.date }} <span class="ml-1 font-normal text-slate-400">{{ tipData.weekday }}</span></div>
<div class="grid grid-cols-2 gap-x-4">
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="text-up">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="text-down">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.close) }}</b></div>
</div>
<div class="tt-row">
涨跌 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.chgPct.toFixed(2) }}%</b>
<div>
涨跌 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.chgPct.toFixed(2) }}%</b>
</div>
<div class="tt-row">振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="tt-sep"></div>
<div class="tt-ind">
<span v-for="(v, k) in tipData.ind" :key="k" :style="{ color: IND_COLOR[k] ?? '#9aa4b2' }">
<div>振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="mt-1 border-t border-slate-100 pt-1">
<span v-for="(v, k) in tipData.ind" :key="k" class="mr-3" :style="{ color: IND_COLOR[k] ?? '#64748b' }">
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
</span>
</div>
</div>
<div ref="container" class="chart-kline"></div>
<div ref="container" class="h-[520px] w-full"></div>
</div>
</template>

View File

@@ -5,16 +5,35 @@ defineProps<{ metrics: MetricsOut }>();
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
const num = (x: number) => x.toFixed(2);
const sign = (x: number) => (x >= 0 ? 'pos' : 'neg'); // A股正=红、负=绿
// A股语义:正=红、负=绿
const sign = (x: number) => (x >= 0 ? 'text-up' : 'text-down');
</script>
<template>
<div class="stats">
<div class="stat"><span class="label">总收益</span><span class="value" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</span></div>
<div class="stat"><span class="label">最大回撤</span><span class="value neg">{{ pct(metrics.max_drawdown) }}</span></div>
<div class="stat"><span class="label">夏普</span><span class="value" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</span></div>
<div class="stat"><span class="label">年化波动</span><span class="value">{{ pct(metrics.volatility) }}</span></div>
<div class="stat"><span class="label">胜率</span><span class="value">{{ pct(metrics.win_rate) }}</span></div>
<div class="stat"><span class="label">交易数</span><span class="value">{{ metrics.num_trades }}</span></div>
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">总收益</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">最大回撤</div>
<div class="mt-1 text-lg font-semibold text-down">{{ pct(metrics.max_drawdown) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">夏普比率</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">年化波动</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.volatility) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">胜率</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.win_rate) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">交易次数</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ metrics.num_trades }}</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ (e: 'run', text: string): void }>();
@@ -22,29 +20,37 @@ function run() {
</script>
<template>
<div class="toolbar" style="flex-direction: column; align-items: stretch; gap: 10px;">
<div class="field" style="gap: 7px;">
<label>用一句话描述你的选股条件</label>
<Textarea
v-model="text"
class="screener-input"
:auto-resize="true"
rows="2"
size="small"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<label class="lbl">用一句话描述你的选股条件</label>
<textarea
v-model="text"
rows="2"
class="ipt w-full resize-y leading-relaxed"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">示例</span>
<button
v-for="(ex, i) in examples"
:key="i"
type="button"
class="max-w-full truncate rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100"
@click="text = ex"
>{{ ex }}</button>
</div>
<div class="quick">
<span class="qlabel">示例</span>
<span v-for="(ex, i) in examples" :key="i" class="qchip" @click="text = ex">{{ ex }}</span>
</div>
<div class="hint">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天等时间窗口
<code>Ctrl+Enter</code> 快速筛选
</div>
<div>
<Button label="开始筛选" icon="pi pi-search" size="small" :loading="loading" :disabled="!text.trim()" @click="run" />
<div class="mt-4 flex items-center justify-between gap-3">
<p class="text-xs leading-relaxed text-slate-400">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口
<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Ctrl</kbd>+<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Enter</kbd> 快速筛选
</p>
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
{{ loading ? '筛选中…' : '开始筛选' }}
</button>
</div>
</div>
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import { computed, ref } from 'vue';
import type { ScreenerItemOut, ScreenerRunResponse } from '@/api/types';
const props = defineProps<{ result: ScreenerRunResponse }>();
const emit = defineEmits<{ (e: 'preview', item: ScreenerItemOut): void }>();
const items = computed(() => props.result.items);
// 动态指标列:[{ key: 'kdj_j', label: 'KDJ J(9,3,3)' }]
@@ -12,64 +11,132 @@ const indCols = computed(() =>
Object.entries(props.result.indicator_labels).map(([key, label]) => ({ key, label })),
);
function pctClass(v: number | null) {
if (v == null) return '';
return v > 0 ? 'pos' : v < 0 ? 'neg' : '';
// ---------- 排序(点击表头切换 升/降) ----------
type FieldOf = (it: ScreenerItemOut) => number | string | null;
const FIXED_COLS: { key: string; label: string; get: FieldOf; num?: boolean; fmt?: (v: number | null) => string; cls?: (v: number | null) => string }[] = [
{ key: 'ts_code', label: '代码', get: (it) => it.ts_code },
{ key: 'name', label: '名称', get: (it) => it.name },
{ key: 'close', label: '最新价', get: (it) => it.close, num: true, fmt: fmt2 },
{
key: 'pct_chg', label: '涨跌幅%', get: (it) => it.pct_chg, num: true,
fmt: (v) => (v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(2)),
cls: (v) => (v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : ''),
},
{ key: 'total_mv', label: '总市值(亿)', get: (it) => it.total_mv, num: true, fmt: fmt2 },
{ key: 'circ_mv', label: '流通市值(亿)', get: (it) => it.circ_mv, num: true, fmt: fmt2 },
{ key: 'pe_ttm', label: 'PE-TTM', get: (it) => it.pe_ttm, num: true, fmt: fmt2 },
{ key: 'pb', label: 'PB', get: (it) => it.pb, num: true, fmt: fmt2 },
{ key: 'turnover_rate', label: '换手率%', get: (it) => it.turnover_rate, num: true, fmt: fmt2 },
];
const sortKey = ref<string>('total_mv');
const sortDir = ref<'asc' | 'desc'>('desc');
function getVal(it: ScreenerItemOut, key: string): number | string | null {
const fixed = FIXED_COLS.find((c) => c.key === key);
if (fixed) return fixed.get(it);
const ind = indCols.value.find((c) => c.key === key);
return ind ? (it.indicators?.[ind.key] ?? null) : null;
}
function fmt(v: number | null, digits = 2) {
return v == null ? '—' : v.toFixed(digits);
function toggleSort(key: string) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDir.value = 'desc'; // 默认数值列降序更直觉
}
}
const sortedItems = computed(() => {
const key = sortKey.value;
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...items.value].sort((a, b) => {
const va = getVal(a, key);
const vb = getVal(b, key);
if (va == null) return 1; // 空值排最后
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb)) * dir;
});
});
function fmt2(v: number | null) {
return v == null ? '—' : v.toFixed(2);
}
function fmtInd(it: ScreenerItemOut, key: string) {
const v = it.indicators?.[key];
return v == null ? '—' : v.toFixed(2);
}
</script>
<template>
<div class="panel">
<div class="panel-title">
命中 {{ result.total }} {{ result.total > items.length ? `(仅显示前 ${items.length}` : '' }}
<span v-if="result.trade_date" style="color: var(--ink-3); margin-left: 8px;">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3 text-[13px] text-slate-600">
命中 <span class="font-semibold text-slate-900">{{ result.total }}</span>
<span v-if="result.total > items.length" class="text-slate-400">仅显示前 {{ items.length }}</span>
<span v-if="result.trade_date" class="ml-2 text-slate-400">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
</div>
<div class="max-h-[560px] overflow-auto">
<table class="w-full border-collapse text-[13px]">
<thead class="sticky top-0 z-10 bg-slate-50 text-slate-500">
<tr class="border-b border-slate-200">
<th
v-for="c in FIXED_COLS"
:key="c.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900"
@click="toggleSort(c.key)"
>
{{ c.label }}
<span v-if="sortKey === c.key" class="text-blue-600">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th
v-for="col in indCols"
:key="col.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900"
@click="toggleSort(col.key)"
>
{{ col.label }}
<span v-if="sortKey === col.key" class="text-blue-600">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="whitespace-nowrap px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="it in sortedItems"
:key="it.ts_code"
class="cursor-pointer border-b border-slate-50 transition-colors last:border-0 hover:bg-blue-50/40"
@click="emit('preview', it)"
>
<td class="whitespace-nowrap px-3 py-1.5 font-medium text-slate-900">{{ it.ts_code }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ it.name }}</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ fmt2(it.close) }}</td>
<td class="whitespace-nowrap px-3 py-1.5" :class="it.pct_chg != null && FIXED_COLS[3].cls ? FIXED_COLS[3].cls!(it.pct_chg) : ''">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.circ_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pe_ttm) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pb) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.turnover_rate) }}</td>
<td v-for="col in indCols" :key="col.key" class="whitespace-nowrap px-3 py-1.5 text-slate-700">
{{ fmtInd(it, col.key) }}
</td>
<td class="whitespace-nowrap px-3 py-1.5 text-right">
<button
type="button"
class="rounded-md border border-slate-200 px-2 py-0.5 text-xs text-blue-600 transition-colors hover:border-blue-300 hover:bg-blue-50"
@click.stop="emit('preview', it)"
>详情</button>
</td>
</tr>
<tr v-if="sortedItems.length === 0">
<td :colspan="FIXED_COLS.length + indCols.length + 1" class="px-3 py-12 text-center text-slate-400">没有符合条件的股票</td>
</tr>
</tbody>
</table>
</div>
<DataTable :value="items" size="small" striped-rows :scrollable="true" scroll-height="560px" sort-mode="single">
<Column field="ts_code" header="代码" sortable style="min-width: 92px;">
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums; color: var(--ink);">{{ data.ts_code }}</span>
</template>
</Column>
<Column field="name" header="名称" sortable style="min-width: 96px;" />
<Column field="close" header="最新价" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.close) }}</template>
</Column>
<Column field="pct_chg" header="涨跌幅%" sortable style="min-width: 88px;">
<template #body="{ data }">
<span :class="pctClass(data.pct_chg)">{{ data.pct_chg == null ? '—' : (data.pct_chg > 0 ? '+' : '') + data.pct_chg.toFixed(2) }}</span>
</template>
</Column>
<Column field="total_mv" header="总市值(亿)" sortable style="min-width: 100px;">
<template #body="{ data }">{{ fmt(data.total_mv) }}</template>
</Column>
<Column field="circ_mv" header="流通市值(亿)" sortable style="min-width: 106px;">
<template #body="{ data }">{{ fmt(data.circ_mv) }}</template>
</Column>
<Column field="pe_ttm" header="PE-TTM" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.pe_ttm) }}</template>
</Column>
<Column field="pb" header="PB" sortable style="min-width: 70px;">
<template #body="{ data }">{{ fmt(data.pb) }}</template>
</Column>
<Column field="turnover_rate" header="换手率%" sortable style="min-width: 88px;">
<template #body="{ data }">{{ fmt(data.turnover_rate) }}</template>
</Column>
<Column
v-for="col in indCols"
:key="col.key"
:field="`indicators.${col.key}`"
:header="col.label"
sortable
style="min-width: 110px;"
>
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums;">{{ data.indicators?.[col.key] == null ? '—' : data.indicators[col.key].toFixed(2) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { getStockPreview } from '@/api/client';
import type { PreviewResponse, ScreenerItemOut } from '@/api/types';
import DetailKLine from './DetailKLine.vue';
const props = defineProps<{
items: ScreenerItemOut[];
initial: string; // ts_code
}>();
const emit = defineEmits<{ (e: 'close'): void }>();
// ---------- 状态 ----------
const active = ref(props.initial);
const data = ref<PreviewResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const filter = ref('');
// 副图指标:点击开关 / 拖拽排序
const SUBS = [
{ key: 'vol', label: 'VOL' },
{ key: 'macd', label: 'MACD' },
{ key: 'kdj', label: 'KDJ' },
{ key: 'rsi', label: 'RSI' },
];
const subPanes = ref<string[]>(['vol', 'macd', 'kdj', 'rsi']);
const showBoll = ref(false);
const filteredItems = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.items;
return props.items.filter(
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q),
);
});
const activeItem = computed(
() => props.items.find((it) => it.ts_code === active.value) ?? null,
);
// 头部/右侧展示值:优先预览信息(最新),否则用选股行数据兜底
const header = computed(() => {
const info = data.value?.info;
const item = activeItem.value;
return {
name: info?.name ?? item?.name ?? active.value,
close: info?.close ?? item?.close ?? null,
pct: info?.pct_chg ?? item?.pct_chg ?? null,
};
});
// ---------- 数据加载 ----------
let fetchToken = 0;
async function load(code: string) {
const token = ++fetchToken;
loading.value = true;
error.value = null;
data.value = null;
try {
const res = await getStockPreview(code);
if (token === fetchToken) data.value = res;
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
watch(active, (code) => load(code), { immediate: true });
function moveActive(delta: number) {
const list = filteredItems.value;
const idx = list.findIndex((it) => it.ts_code === active.value);
if (list.length === 0) return;
const next = idx < 0 ? 0 : Math.min(list.length - 1, Math.max(0, idx + delta));
active.value = list[next].ts_code;
}
// ---------- 键盘 / 滚动锁 ----------
function onKeydown(e: KeyboardEvent) {
// 输入法组合态 / 焦点在输入框时不拦截否则搜索框打字会切股、Esc 关浮层)
if (e.isComposing) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'Escape') emit('close');
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
document.body.style.overflow = 'hidden';
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
// ---------- 副图 chips开关 + 拖拽排序 ----------
let dragKey: string | null = null;
function toggleSub(key: string) {
subPanes.value = subPanes.value.includes(key)
? subPanes.value.filter((k) => k !== key)
: [...subPanes.value, key];
}
function onDragStart(e: DragEvent, key: string) {
dragKey = key;
// Firefox/Safari 要求 dragstart 写入数据才会真正发起拖拽
e.dataTransfer?.setData('text/plain', key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDrop(target: string) {
if (!dragKey || dragKey === target) return;
const arr = [...subPanes.value];
const from = arr.indexOf(dragKey);
if (from >= 0) arr.splice(from, 1);
const to = arr.indexOf(target);
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
subPanes.value = arr;
dragKey = null;
}
// ---------- 格式化 ----------
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
const fmtInt = (v: number | null | undefined) =>
v == null ? '—' : Math.round(v).toLocaleString();
const pctClass = (v: number | null | undefined) =>
v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '';
const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s ?? '—');
</script>
<template>
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100">
<!-- 顶栏 -->
<header class="flex h-12 shrink-0 items-center gap-4 border-b border-slate-200 bg-white px-4">
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
<span class="text-xs text-slate-400">{{ active }}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold" :class="pctClass(header.pct)">{{ fmt(header.close) }}</span>
<span v-if="header.pct != null" class="text-sm" :class="pctClass(header.pct)">
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
</span>
</div>
<span v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600">
近段未复权数据
</span>
<span v-else-if="data" class="rounded bg-blue-50 px-2 py-0.5 text-[11px] text-blue-600">前复权</span>
<span class="ml-auto text-xs text-slate-400"> 切换 · Esc 关闭</span>
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</header>
<!-- 三栏主体 -->
<div class="flex min-h-0 flex-1">
<!-- 命中列表 -->
<aside class="flex w-56 shrink-0 flex-col border-r border-slate-200 bg-white">
<div class="border-b border-slate-100 p-2">
<input v-model="filter" type="text" class="ipt w-full !py-1 text-xs" placeholder="搜索代码 / 名称" />
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<button
v-for="it in filteredItems"
:key="it.ts_code"
type="button"
class="flex w-full items-center gap-2 border-b border-slate-50 px-3 py-2 text-left transition-colors"
:class="it.ts_code === active ? 'bg-blue-50' : 'hover:bg-slate-50'"
@click="active = it.ts_code"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-medium text-slate-800">{{ it.name }}</span>
<span class="block text-[11px] text-slate-400">{{ it.ts_code }}</span>
</span>
<span class="text-right">
<span class="block text-[13px]">{{ fmt(it.close) }}</span>
<span class="block text-[11px]" :class="pctClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
</span>
</span>
</button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-xs text-slate-400">无匹配</div>
</div>
<div class="border-t border-slate-100 px-3 py-2 text-[11px] text-slate-400"> {{ filteredItems.length }} </div>
</aside>
<!-- K线 + 指标面板 -->
<section class="flex min-w-0 flex-1 flex-col">
<!-- 指标开关 / 排序 -->
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-white px-3 py-2">
<span class="text-[11px] text-slate-400">副图</span>
<button
v-for="s in SUBS"
:key="s.key"
type="button"
draggable="true"
class="cursor-grab rounded-md border px-2.5 py-1 text-xs transition-colors active:cursor-grabbing"
:class="subPanes.includes(s.key)
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-white text-slate-400 line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序' : '点击显示'"
@click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)"
@dragover.prevent
@drop="onDrop(s.key)"
>
{{ s.label }}
</button>
<button
type="button"
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
:class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-slate-200 bg-white text-slate-400'"
title="主图叠加布林带"
@click="showBoll = !showBoll"
>BOLL</button>
<span class="ml-2 text-[11px] text-slate-400">点击开关副图 · 拖动排序 · 滚轮缩放 · 拖拽平移</span>
</div>
<!-- 图表 -->
<div class="relative min-h-0 flex-1 bg-white p-1">
<div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 text-sm text-slate-400">
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ active }} 首次查看需拉取全量日线
</div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
<DetailKLine
v-else-if="data && data.candles.length"
:ticker="data.ts_code"
:candles="data.candles"
:indicators="data.indicators"
:sub-panes="subPanes"
:show-boll="showBoll"
/>
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
</div>
</section>
<!-- 个股信息通达信式 -->
<aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-slate-200 bg-white p-4">
<div class="border-b border-slate-100 pb-3">
<div class="text-[15px] font-semibold text-slate-900">{{ data.info.name }}</div>
<div class="mt-0.5 text-xs text-slate-400">
{{ data.info.ts_code }}
<span v-if="data.info.market" class="ml-1 rounded bg-slate-100 px-1.5 py-0.5">{{ data.info.market }}</span>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-semibold" :class="pctClass(data.info.pct_chg)">{{ fmt(data.info.close) }}</span>
<span v-if="data.info.pct_chg != null" class="text-sm" :class="pctClass(data.info.pct_chg)">
{{ data.info.pct_chg > 0 ? '+' : '' }}{{ fmt(data.info.pct_chg) }}%
</span>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-y-2 text-[13px]">
<template v-for="(row, i) in [
['今开', fmt(data.info.open)],
['昨收', fmt(data.info.pre_close)],
['最高', fmt(data.info.high)],
['最低', fmt(data.info.low)],
['成交量', fmtInt(data.info.volume_hand) + ' 手'],
['成交额', fmt(data.info.amount_yi) + ' 亿'],
['换手率', fmt(data.info.turnover_rate) + '%'],
['市盈率TTM', fmt(data.info.pe_ttm)],
['市净率', fmt(data.info.pb)],
['总市值', fmt(data.info.total_mv) + ' 亿'],
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
['上市日期', fmtListDate(data.info.list_date)],
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
]" :key="i">
<span class="text-slate-400">{{ row[0] }}</span>
<span class="text-right text-slate-800">{{ row[1] }}</span>
</template>
</div>
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
<div class="mb-2 text-xs text-slate-400">归属</div>
<div class="flex flex-wrap gap-1.5">
<span v-if="data.info.industry" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.industry }}</span>
<span v-if="data.info.area" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.area }}</span>
</div>
</div>
</aside>
</div>
</div>
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import Button from 'primevue/button';
import type { ScreenerSyncStatus } from '@/api/types';
const props = defineProps<{ status: ScreenerSyncStatus | null }>();
@@ -15,7 +14,7 @@ function fmtDate(s?: string | null) {
const freshness = computed(() => {
const s = props.status;
if (!s) return { tone: 'none', text: '正在检查数据状态…' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步数据」' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步市场数据」' };
const snapMissing = s.stats.snapshot_rows === 0;
const d = s.stats.dates;
const base = `数据截至 ${fmtDate(s.last_trade_date)} · 共 ${d} 个交易日 · ${s.stats.stocks} 只股票`;
@@ -25,32 +24,41 @@ const freshness = computed(() => {
</script>
<template>
<div class="sync-bar">
<span :class="freshness.tone === 'ok' ? 'ok' : freshness.tone === 'warn' ? 'warn' : ''">
<i class="pi" :class="freshness.tone === 'ok' ? 'pi-check-circle' : 'pi-info-circle'" style="margin-right: 5px;"></i>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-[13px] text-slate-600">
<span :class="freshness.tone === 'ok' ? 'text-emerald-600' : freshness.tone === 'warn' ? 'text-amber-600' : 'text-slate-400'">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<template v-if="freshness.tone === 'ok'">
<path d="M22 11.1V12a10 10 0 11-5.9-9.1" />
<path d="M22 4L12 14l-3-3-7 7" />
</template>
<template v-else>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</template>
</svg>
{{ freshness.text }}
</span>
<template v-if="status && status.running">
<span class="sync-progress">
<i class="pi pi-spin pi-spinner"></i>
<span class="txt">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
<span class="flex min-w-[200px] flex-1 items-center gap-2">
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<span class="whitespace-nowrap text-xs text-slate-500">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
<span class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: (status.total_days ? Math.min(100, (status.done_days / status.total_days) * 100) : 0) + '%' }" />
</span>
</span>
</template>
<template v-else>
<span class="spacer"></span>
<Button
label="同步市场数据"
icon="pi pi-refresh"
size="small"
severity="secondary"
:disabled="status?.running"
@click="emit('sync')"
/>
<span class="flex-1"></span>
<button type="button" class="btn-ghost shrink-0 disabled:opacity-50" :disabled="status?.running" @click="emit('sync')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步市场数据
</button>
</template>
<div v-if="status && status.error" class="warn" style="flex-basis: 100%; margin-top: 4px;">
<i class="pi pi-exclamation-triangle" style="margin-right: 5px;"></i>{{ status.error }}
<div v-if="status && status.error" class="w-full text-amber-600">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ status.error }}
</div>
</div>
</template>

View File

@@ -1,24 +1,23 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css';
import './style.css';
import App from './App.vue';
import router from './router';
import { useAuthStore } from '@/stores/auth';
const app = createApp(App);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
// 以后想做深色:给 <html> 加 .app-dark 即可
darkModeSelector: '.app-dark',
},
},
window.addEventListener('stock:unauthorized', () => {
const auth = useAuthStore(pinia);
auth.clear();
if (router.currentRoute.value.name !== 'login') {
void router.replace({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } });
}
});
app.mount('#app');

View File

@@ -1,9 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
{ path: '/', name: 'home', component: HomeView },
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
@@ -11,4 +13,21 @@ const router = createRouter({
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
await auth.restore();
if (to.meta.public) {
if (to.name === 'login' && auth.isAuthenticated) {
return typeof to.query.redirect === 'string' ? to.query.redirect : '/';
}
return true;
}
if (!auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return true;
});
export default router;

View File

@@ -0,0 +1,60 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { ApiError, getCurrentUser, login as requestLogin, logout as requestLogout } from '@/api/client';
import type { CurrentUser } from '@/api/types';
export const useAuthStore = defineStore('auth', () => {
const user = ref<CurrentUser | null>(null);
const initialized = ref(false);
const loading = ref(false);
let restorePromise: Promise<void> | null = null;
const isAuthenticated = computed(() => user.value !== null);
async function restore() {
if (initialized.value) return;
if (restorePromise) return restorePromise;
restorePromise = (async () => {
try {
user.value = await getCurrentUser();
} catch (error) {
if (error instanceof ApiError && error.status !== 401) {
console.warn('Unable to restore login session:', error.message);
}
user.value = null;
} finally {
initialized.value = true;
restorePromise = null;
}
})();
return restorePromise;
}
async function login(username: string, password: string) {
loading.value = true;
try {
const result = await requestLogin({ username, password });
user.value = result.user;
initialized.value = true;
} finally {
loading.value = false;
}
}
async function logout() {
try {
await requestLogout();
} finally {
user.value = null;
initialized.value = true;
}
}
function clear() {
user.value = null;
initialized.value = true;
}
return { user, initialized, loading, isAuthenticated, restore, login, logout, clear };
});

View File

@@ -1,186 +1,117 @@
:root {
/* 深色专业交易终端配色A股红涨绿跌 */
--bg: #0b0e14;
--surface: #11151c;
--surface-2: #161c26;
--border: rgba(255, 255, 255, 0.07);
--border-2: rgba(255, 255, 255, 0.12);
@import "tailwindcss";
--ink: #e6edf3;
--ink-2: #9aa4b2;
--ink-3: #5c6675;
--up: #f6465d; /* A股涨 / 买入 = 红 */
--down: #0ecb81; /* A股跌 / 卖出 = 绿 */
--dif: #5b8ff9; /* MACD DIF */
--dea: #f6bd16; /* MACD DEA */
--radius: 12px;
/* ---------- 主题浅色简洁A股语义红涨绿跌 ---------- */
@theme {
--color-up: #dc2626; /* 涨 / 买入 = 红 */
--color-down: #16a34a; /* 跌 / 卖出 = 绿 */
--color-accent: #2563eb;
--font-sans: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; }
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-variant-numeric: tabular-nums;
}
/* ---------- 登录:个人量化终端入口 ---------- */
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(1200px 560px at 78% -12%, #182030 0%, rgba(24, 32, 48, 0) 55%),
var(--bg);
color: var(--ink);
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
linear-gradient(#e2e8f0 1px, transparent 1px),
linear-gradient(90deg, #e2e8f0 1px, transparent 1px),
#f8fafc;
background-size: 40px 40px;
}
.app-shell { max-width: 1500px; margin: 0 auto; padding: 16px 20px 40px; }
.app-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.app-header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.3px; }
.app-header .sub { color: var(--ink-3); font-size: 12px; }
/* 参数工具栏 */
.toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 11px 14px;
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px;
.login-panel {
width: min(100%, 420px);
border: 1px solid #cbd5e1;
border-top: 3px solid #2563eb;
border-radius: 6px;
background: #fff;
padding: 30px;
box-shadow: 0 18px 50px rgb(15 23 42 / 10%);
}
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.5px; }
.spacer { flex: 1 1 auto; }
.hint { margin-top: 8px; font-size: 12px; color: var(--ink-3); }
.hint code { color: var(--ink-2); background: rgba(255,255,255,0.05); padding: 1px 5px; border-radius: 4px; }
/* 标的快捷选择 */
.quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
.quick .qlabel { font-size: 12px; color: var(--ink-3); margin-right: 2px; }
.qchip {
font-size: 12px; color: var(--ink-2); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; cursor: pointer;
font-variant-numeric: tabular-nums; transition: 0.15s;
.login-brand { display: flex; align-items: center; gap: 12px; }
.login-brand-mark {
display: grid; place-items: center; width: 38px; height: 38px; border-radius: 5px;
background: #0f172a; color: #fff; font-size: 17px; font-weight: 700;
}
.qchip:hover { color: var(--ink); border-color: var(--border-2); }
.qchip.active { color: #fff; background: var(--dif); border-color: var(--dif); }
.qchip .qname { color: var(--ink-3); margin-left: 4px; }
.qchip.active .qname { color: rgba(255, 255, 255, 0.85); }
.login-brand-name { color: #0f172a; font-size: 14px; font-weight: 650; }
.login-brand-meta { margin-top: 2px; color: #64748b; font-size: 9px; font-family: ui-monospace, monospace; }
/* 绩效指标:紧凑单行数据条 */
.stats { display: flex; gap: 8px; margin-top: 12px; }
.stat {
flex: 1 1 0; min-width: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 11px; display: flex; align-items: baseline; gap: 7px; white-space: nowrap;
}
.stat .label { font-size: 11px; color: var(--ink-3); }
.stat .value { font-size: 14px; font-weight: 600; }
.stat .value.pos { color: var(--up); }
.stat .value.neg { color: var(--down); }
.market-track { display: grid; grid-template-columns: repeat(13, 1fr); height: 17px; margin: 26px 0 20px; border-bottom: 1px solid #cbd5e1; }
.market-track span { width: 1px; height: 5px; align-self: end; background: #94a3b8; }
.market-track span.major { height: 10px; background: #2563eb; }
/* 图表面板 */
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 10px 6px; margin-top: 14px;
}
.panel-title { font-size: 12px; color: var(--ink-2); margin: 4px 4px 6px; }
.login-heading h1 { margin-top: 7px; color: #0f172a; font-size: 28px; line-height: 1.2; font-weight: 680; }
.login-heading > p:last-child { margin-top: 8px; color: #64748b; font-size: 13px; line-height: 1.7; }
.login-status { display: flex; align-items: center; gap: 7px; color: #475569; font-size: 11px; font-weight: 600; }
.login-status span { width: 7px; height: 7px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 3px #dcfce7; }
.kline-wrap { position: relative; }
.chart-kline { height: 520px; }
.chart-equity { height: 240px; }
.login-form { display: grid; gap: 18px; margin-top: 26px; }
.login-form label > span { display: block; margin-bottom: 7px; color: #475569; font-size: 12px; font-weight: 600; }
.login-form input {
width: 100%; height: 42px; border: 1px solid #cbd5e1; border-radius: 5px; background: #fff;
padding: 0 12px; color: #0f172a; font-size: 14px; outline: none; transition: border-color .15s, box-shadow .15s;
}
.login-form input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px #dbeafe; }
.password-field { position: relative; }
.password-field input { padding-right: 42px; }
.password-toggle {
position: absolute; top: 3px; right: 3px; display: grid; place-items: center; width: 36px; height: 36px;
border-radius: 4px; color: #64748b;
}
.password-toggle:hover { background: #f1f5f9; color: #0f172a; }
.password-toggle:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
.password-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* K 线左上角标识 + 系列图例 */
.lc-legend {
position: absolute; top: 6px; left: 10px; z-index: 5;
display: flex; gap: 14px; align-items: center;
font-size: 12px; color: var(--ink-2); pointer-events: none;
.login-error { border-left: 3px solid #dc2626; background: #fef2f2; padding: 9px 11px; color: #b91c1c; font-size: 12px; line-height: 1.5; }
.login-submit {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px;
border-radius: 5px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 650;
transition: background .15s, transform .1s;
}
.lc-legend .sym { color: var(--ink); font-weight: 600; }
.lc-legend .sym small { color: var(--ink-3); font-weight: 400; margin-left: 6px; }
.lc-legend .chip { display: inline-flex; align-items: center; gap: 5px; }
.lc-legend .chip i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
.login-submit:hover:not(:disabled) { background: #1d4ed8; }
.login-submit:active:not(:disabled) { transform: translateY(1px); }
.login-submit:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.login-submit:disabled { cursor: not-allowed; opacity: .55; }
.login-spinner { width: 14px; height: 14px; border: 2px solid rgb(255 255 255 / 45%); border-top-color: #fff; border-radius: 50%; animation: login-spin .7s linear infinite; }
.login-footnote { margin-top: 22px; border-top: 1px solid #e2e8f0; padding-top: 16px; color: #94a3b8; font-size: 10px; text-align: center; }
/* 悬停弹框(同花顺式) */
.lc-tooltip {
position: absolute; z-index: 20; pointer-events: none; min-width: 196px;
background: rgba(17, 21, 28, 0.97); border: 1px solid var(--border-2);
border-radius: 8px; padding: 8px 11px; font-size: 11.5px; color: var(--ink-2);
line-height: 1.7; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
@keyframes login-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .login-spinner { animation: none; } }
@media (max-width: 480px) {
.login-shell { padding: 14px; background-size: 32px 32px; }
.login-panel { padding: 24px 20px; }
}
.lc-tooltip .tt-date { color: var(--ink); font-weight: 600; margin-bottom: 3px; }
.lc-tooltip .tt-date .tt-wd { color: var(--ink-3); font-weight: 400; margin-left: 5px; }
.lc-tooltip .tt-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.lc-tooltip .tt-grid b { color: var(--ink); font-weight: 500; float: right; }
.lc-tooltip .tt-row b { color: var(--ink); font-weight: 500; }
.lc-tooltip .tt-sep { height: 1px; background: var(--border); margin: 5px 0; }
.lc-tooltip .tt-ind span { margin-right: 10px; }
.lc-tooltip .pos { color: var(--up); }
.lc-tooltip .neg { color: var(--down); }
.error-banner {
background: rgba(246, 70, 93, 0.08); color: var(--up);
border: 1px solid rgba(246, 70, 93, 0.3); border-radius: var(--radius);
padding: 10px 14px; font-size: 13px; margin-top: 14px;
}
.placeholder { color: var(--ink-3); font-size: 13px; padding: 56px 0; text-align: center; }
/* ---------- 少量可复用控件样式(其余全部用 Tailwind 原子类) ---------- */
@layer components {
/* 表单输入 */
.ipt {
@apply rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-900
outline-none transition-colors placeholder:text-slate-400
focus:border-blue-500 focus:ring-2 focus:ring-blue-100;
}
select.ipt { @apply pr-7 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22%2364748b%22%3E%3Cpath%20d%3D%22M4.5%206l3.5%203.5L11.5%206z%22%2F%3E%3C%2Fsvg%3E')] bg-[length:16px] bg-[right_0.4rem_center] bg-no-repeat; }
/* ---------- 顶部导航 ---------- */
.app-nav { display: flex; gap: 4px; margin-left: 18px; }
.nav-link {
font-size: 13px; color: var(--ink-2); text-decoration: none;
padding: 5px 12px; border-radius: 8px; transition: 0.15s;
}
.nav-link:hover { color: var(--ink); background: var(--surface-2); }
.nav-link.router-link-active { color: #fff; background: var(--dif); }
/* 主 / 次按钮 */
.btn-primary {
@apply inline-flex items-center justify-center gap-1.5 rounded-md bg-blue-600 px-3.5 py-1.5 text-sm
font-medium text-white transition-colors hover:bg-blue-700 active:bg-blue-800
disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-300 bg-white
px-3.5 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50
active:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50;
}
/* ---------- 首页功能入口卡片 ---------- */
.home-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px; margin-top: 22px;
/* 字段标签 */
.lbl { @apply mb-1 block text-xs font-medium tracking-wide text-slate-500; }
}
.feature-card {
display: block; text-decoration: none; color: var(--ink);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 26px 24px 20px; transition: 0.18s; position: relative;
}
.feature-card:hover { border-color: var(--border-2); transform: translateY(-2px); box-shadow: 0 14px 34px rgba(0,0,0,0.35); }
.fc-icon {
width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center;
background: rgba(91, 143, 249, 0.12); color: var(--dif); font-size: 20px; margin-bottom: 14px;
}
.fc-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.fc-desc { font-size: 13px; color: var(--ink-2); line-height: 1.75; min-height: 66px; }
.fc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 16px; }
.fc-tag {
font-size: 11.5px; color: var(--ink-3); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px;
}
.fc-enter { font-size: 13px; color: var(--dif); display: flex; align-items: center; gap: 6px; }
/* ---------- 智能选股 ---------- */
.screener-input { width: 100%; }
.cond-chips { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.cond-chips .clabel { font-size: 12px; color: var(--ink-3); margin-right: 3px; }
.cond-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; color: var(--ink); background: var(--surface-2);
border: 1px solid var(--border-2); border-radius: 999px; padding: 4px 11px;
font-variant-numeric: tabular-nums;
}
.cond-chip.ci { border-color: rgba(91, 143, 249, 0.45); color: #cdddfb; }
.cond-chip.cs { border-color: rgba(246, 189, 22, 0.4); color: #f2e3b3; }
.cond-chip.cx { color: var(--ink-3); }
.cond-chip .arrow { color: var(--ink-3); }
/* 数据状态条 */
.sync-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 14px; margin-top: 14px; font-size: 12.5px; color: var(--ink-2);
}
.sync-bar .ok { color: var(--down); }
.sync-bar .warn { color: var(--dea); }
.sync-bar .sync-progress { flex: 1 1 200px; min-width: 160px; display: flex; align-items: center; gap: 8px; }
.sync-bar .sync-progress .txt { white-space: nowrap; }
/* 结果表数字着色 */
.pos { color: var(--up); }
.neg { color: var(--down); }

View File

@@ -15,14 +15,20 @@ function onRun(req: BacktestRequest) {
<template>
<BacktestForm :loading="store.loading" @run="onRun" />
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="placeholder">{{ store.note }}</div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
</div>
<template v-if="store.result">
<MetricsPanel :metrics="store.result.metrics" />
<div class="panel">
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<KLineChart
:candles="store.result.candles"
:indicators="store.result.indicators"
@@ -33,13 +39,13 @@ function onRun(req: BacktestRequest) {
/>
</div>
<div class="panel">
<div class="panel-title">净值曲线</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<div class="px-2 py-1 text-[13px] text-slate-500">净值曲线</div>
<EquityChart :equity="store.result.equity" />
</div>
</template>
<div v-else-if="!store.loading" class="placeholder">
<div v-else-if="!store.loading" class="py-16 text-center text-sm text-slate-400">
选好周期与参数开始回测先用 DEMO 合成数据跑通
</div>
</template>

View File

@@ -5,31 +5,42 @@ import { RouterLink } from 'vue-router';
const features = [
{
to: '/screener',
icon: 'pi pi-sparkles',
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
accent: 'bg-blue-50 text-blue-600',
title: '智能选股',
desc: '用一句自然语言描述条件(如「这两天 KDJ 的 J 小于 10市值 100~200 亿」AI 解析后全市场筛选。',
tags: ['KDJ / RSI / MACD / 布林', '市值 · 市盈率 · 换手率', 'AI 条件解析'],
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
},
{
to: '/backtest',
icon: 'pi pi-chart-line',
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
accent: 'bg-emerald-50 text-emerald-600',
title: '策略回测',
desc: '选标的、调参数跑历史回测K 线 + 指标 + 买卖点 + 净值曲线与绩效指标A股真实成本建模。',
tags: ['MACD / 双均线 / 单均线', 'T+1 · 印花税 · 佣金', '净值与绩效'],
desc: '选标的与策略参数,查看历史表现和关键绩效指标。',
},
];
</script>
<template>
<div class="home-grid">
<RouterLink v-for="f in features" :key="f.to" :to="f.to" class="feature-card">
<div class="fc-icon"><i :class="f.icon"></i></div>
<div class="fc-title">{{ f.title }}</div>
<div class="fc-desc">{{ f.desc }}</div>
<div class="fc-tags">
<span v-for="t in f.tags" :key="t" class="fc-tag">{{ t }}</span>
</div>
<div class="fc-enter">进入 <i class="pi pi-arrow-right"></i></div>
</RouterLink>
<div class="w-full max-w-5xl">
<div class="grid gap-6 sm:grid-cols-2">
<RouterLink
v-for="f in features"
:key="f.to"
:to="f.to"
class="group flex min-h-72 flex-col rounded-lg border border-slate-200 bg-white p-8 transition-all hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 sm:min-h-80 sm:p-10"
>
<span :class="['flex h-14 w-14 items-center justify-center rounded-lg', f.accent]">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" />
</svg>
</span>
<div class="mt-7 text-2xl font-semibold">{{ f.title }}</div>
<p class="mt-3 max-w-sm text-sm leading-6 text-slate-500">{{ f.desc }}</p>
<div class="mt-auto flex items-center gap-1.5 pt-8 text-sm font-medium text-blue-600">
进入
<svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</div>
</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ApiError } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const username = ref('');
const password = ref('');
const error = ref('');
const passwordVisible = ref(false);
const canSubmit = computed(() => username.value.trim().length > 0 && password.value.length > 0 && !auth.loading);
async function submit() {
if (!canSubmit.value) return;
error.value = '';
try {
await auth.login(username.value.trim(), password.value);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.replace(redirect);
} catch (cause) {
error.value = cause instanceof ApiError ? cause.message : '暂时无法登录,请稍后重试';
}
}
</script>
<template>
<main class="login-shell">
<section class="login-panel" aria-labelledby="login-title">
<div class="login-brand">
<span class="login-brand-mark" aria-hidden="true"></span>
<div>
<p class="login-brand-name">量化选股与回测</p>
<p class="login-brand-meta">PRIVATE RESEARCH TERMINAL</p>
</div>
</div>
<div class="market-track" aria-hidden="true">
<span v-for="n in 13" :key="n" :class="{ major: n === 1 || n === 7 || n === 13 }" />
</div>
<div class="login-heading">
<p class="login-status"><span /> 私有工作区</p>
<h1 id="login-title">登录</h1>
<p>验证身份后进入行情研究与策略回测</p>
</div>
<form class="login-form" @submit.prevent="submit">
<label>
<span>用户名</span>
<input v-model="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus />
</label>
<label>
<span>密码</span>
<div class="password-field">
<input v-model="password" :type="passwordVisible ? 'text' : 'password'" name="password" autocomplete="current-password" required />
<button type="button" class="password-toggle" :aria-label="passwordVisible ? '隐藏密码' : '显示密码'" :title="passwordVisible ? '隐藏密码' : '显示密码'" @click="passwordVisible = !passwordVisible">
<svg v-if="!passwordVisible" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.3 17.3 0 0 1-2.1 3M6.6 6.7C3.6 8.7 2 12 2 12s3.5 7 10 7a10.7 10.7 0 0 0 4-.8" /></svg>
</button>
</div>
</label>
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
<button class="login-submit" type="submit" :disabled="!canSubmit">
<span v-if="auth.loading" class="login-spinner" aria-hidden="true" />
{{ auth.loading ? '正在验证' : '进入工作台' }}
</button>
</form>
<p class="login-footnote">会话将在到期后自动失效</p>
</section>
</main>
</template>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useScreenerStore } from '@/stores/screener';
import ScreenerForm from '@/components/ScreenerForm.vue';
import ConditionChips from '@/components/ConditionChips.vue';
import SyncStatusBar from '@/components/SyncStatusBar.vue';
import ScreenerTable from '@/components/ScreenerTable.vue';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
const store = useScreenerStore();
const previewCode = ref<string | null>(null);
onMounted(() => {
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
@@ -22,23 +24,33 @@ onBeforeUnmount(() => store.stopPolling());
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
<div v-if="store.error" class="error-banner">
<i class="pi pi-exclamation-triangle" style="margin-right: 6px;"></i>{{ store.error }}
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="placeholder">
<i class="pi pi-spin pi-spinner" style="margin-right: 8px;"></i>{{ store.note }}
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
</div>
<template v-else-if="store.result">
<div class="panel" style="padding: 12px 14px; margin-top: 14px;">
<div class="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<ConditionChips :conditions="store.result.conditions" />
</div>
<ScreenerTable :result="store.result" />
<ScreenerTable :result="store.result" @preview="previewCode = $event.ts_code" />
</template>
<div v-else-if="!store.error" class="placeholder">
<div v-else-if="!store.error" class="py-16 text-center text-sm text-slate-400">
输入选股条件开始筛选即可全市场选股
</div>
<!-- 全屏个股详情同花顺/通达信式 -->
<StockDetailOverlay
v-if="previewCode && store.result"
:items="store.result.items"
:initial="previewCode"
@close="previewCode = null"
/>
</div>
</template>

View File

@@ -1,10 +1,11 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},