This commit is contained in:
薇薇安 2026-01-22 21:30:21 +08:00
parent d6cdc2f055
commit d051be3f65
11 changed files with 388 additions and 217 deletions

97
frontend/REDUX_SETUP.md Normal file
View File

@ -0,0 +1,97 @@
# Redux 全局状态管理设置说明
## 📦 安装依赖
请手动运行以下命令安装 Redux 相关依赖:
```bash
cd frontend
npm install @reduxjs/toolkit react-redux
```
## ✅ 已完成的实现
### 1. Redux Store 结构
**文件**: `frontend/src/store/index.js`
- 配置了 Redux store
- 使用 `@reduxjs/toolkit``configureStore`
**文件**: `frontend/src/store/appSlice.js`
- 管理全局状态:
- `currentUser`: 当前登录用户
- `viewingUserId`: 管理员查看的用户ID
- `accountId`: 当前选中的账号ID
- `accounts`: 当前用户的账号列表
- `users`: 用户列表(管理员可见)
### 2. 已更新的组件
#### App.jsx
- ✅ 使用 `Provider` 包裹整个应用
- ✅ 使用 Redux hooks (`useSelector`, `useDispatch`)
- ✅ 用户切换逻辑使用 Redux actions
#### AccountSelector.jsx
- ✅ 完全使用 Redux 管理账号选择
- ✅ 用户切换时自动选择第一个 active 账号
- ✅ 账号切换时自动同步到 Redux store
#### StatsDashboard.jsx
- ✅ 使用 `useSelector(selectAccountId)` 获取当前账号ID
- ✅ 当 `accountId` 变化时自动重新加载数据
#### TradeList.jsx
- ✅ 使用 `useSelector(selectAccountId)` 获取当前账号ID
- ✅ 当 `accountId` 变化时自动重新加载数据
#### Recommendations.jsx
- ✅ 使用 `useSelector(selectAccountId)` 获取当前账号ID
- ✅ 当 `accountId` 变化时自动更新默认下单量
#### ConfigPanel.jsx
- ✅ 使用 Redux 获取 `accountId`, `currentUser`, `isAdmin`
- ✅ 移除了 localStorage 轮询和事件监听
- ✅ 当 `accountId` 变化时自动重新加载配置
#### GlobalConfig.jsx
- ✅ 使用 Redux 获取 `currentUser`, `isAdmin`
- ✅ 移除了 props 传递
## 🔄 工作流程
### 用户切换流程
1. 管理员点击切换用户
2. `App.jsx` 调用 `dispatch(switchUser(userId))`
3. Redux store 更新 `viewingUserId`
4. `AccountSelector` 监听到 `viewingUserId` 变化
5. 自动加载新用户的账号列表
6. 自动选择第一个 active 账号
7. Redux store 更新 `accountId`
8. 所有使用 `useSelector(selectAccountId)` 的组件自动重新渲染
9. 各组件在 `useEffect` 中检测到 `accountId` 变化,重新加载数据
### 账号切换流程
1. 用户在 `AccountSelector` 中选择账号
2. `AccountSelector` 调用 `dispatch(setAccountId(accountId))`
3. Redux store 更新 `accountId`
4. 所有使用 `useSelector(selectAccountId)` 的组件自动重新渲染
5. 各组件在 `useEffect` 中检测到 `accountId` 变化,重新加载数据
## 🎯 优势
1. **统一状态管理**: 所有用户和账号状态都在 Redux store 中
2. **自动同步**: 切换用户/账号后,所有页面自动更新
3. **响应式**: 使用 React hooks状态变化自动触发重新渲染
4. **易于调试**: Redux DevTools 可以查看所有状态变化
5. **向后兼容**: 保留了自定义事件 (`ats:account:changed`),确保旧代码仍能工作
## 📝 注意事项
1. **需要安装依赖**: 请运行 `npm install @reduxjs/toolkit react-redux`
2. **localStorage 同步**: Redux store 会自动同步到 localStorage确保刷新后状态不丢失
3. **事件兼容**: 保留了 `ats:account:changed` 事件,但主要使用 Redux 状态管理
## 🚀 下一步
安装依赖后,系统将完全使用 Redux 管理用户和账号状态,切换用户/账号时所有页面会自动同步更新。

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import ConfigPanel from './components/ConfigPanel' import ConfigPanel from './components/ConfigPanel'
import ConfigGuide from './components/ConfigGuide' import ConfigGuide from './components/ConfigGuide'
import TradeList from './components/TradeList' import TradeList from './components/TradeList'
@ -9,77 +10,56 @@ import LogMonitor from './components/LogMonitor'
import AccountSelector from './components/AccountSelector' import AccountSelector from './components/AccountSelector'
import GlobalConfig from './components/GlobalConfig' import GlobalConfig from './components/GlobalConfig'
import Login from './components/Login' import Login from './components/Login'
import { api, clearAuthToken, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api' import { api, clearAuthToken } from './services/api'
import {
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id' setCurrentUser,
setViewingUserId,
setUsers,
switchUser,
selectCurrentUser,
selectViewingUserId,
selectUsers,
selectIsAdmin,
selectEffectiveUserId,
} from './store/appSlice'
import './App.css' import './App.css'
function App() { function App() {
const [me, setMe] = useState(null) const dispatch = useDispatch()
const currentUser = useSelector(selectCurrentUser)
const viewingUserId = useSelector(selectViewingUserId)
const users = useSelector(selectUsers)
const isAdmin = useSelector(selectIsAdmin)
const effectiveUserId = useSelector(selectEffectiveUserId)
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [users, setUsers] = useState([])
const [viewingUserId, setViewingUserId] = useState(() => {
// localStorageID
const stored = localStorage.getItem(VIEWING_USER_ID_KEY)
if (stored) {
const parsed = parseInt(stored, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
return null
})
const [showUserPopover, setShowUserPopover] = useState(false) const [showUserPopover, setShowUserPopover] = useState(false)
const userPopoverRef = React.useRef(null) const userPopoverRef = React.useRef(null)
const refreshMe = async () => { const refreshMe = async () => {
try { try {
const u = await api.me() const u = await api.me()
setMe(u) dispatch(setCurrentUser(u))
const isAdmin = (u?.role || '') === 'admin' const isAdminValue = (u?.role || '') === 'admin'
// //
if (isAdmin) { if (isAdminValue) {
try { try {
const userList = await api.getUsers() const userList = await api.getUsers()
setUsers(Array.isArray(userList) ? userList : []) const usersArray = Array.isArray(userList) ? userList : []
dispatch(setUsers(usersArray))
// viewingUserId // viewingUserId
const currentUserId = parseInt(String(u?.id || ''), 10) const currentUserId = parseInt(String(u?.id || ''), 10)
if (!viewingUserId || !userList.some((user) => parseInt(String(user?.id || ''), 10) === viewingUserId)) { if (!viewingUserId || !usersArray.some((user) => parseInt(String(user?.id || ''), 10) === viewingUserId)) {
setViewingUserId(currentUserId) dispatch(setViewingUserId(currentUserId))
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId))
} }
} catch (e) { } catch (e) {
console.error('加载用户列表失败:', e) console.error('加载用户列表失败:', e)
} }
} }
// admin/沿 accountId
try {
const list = await api.getAccounts()
const accounts = Array.isArray(list) ? list : []
const active = accounts.filter((a) => String(a?.status || 'active') === 'active')
let target = null
if (isAdmin) {
// active 沿
target = active[0]?.id || accounts[0]?.id
} else {
// active active
const uid = parseInt(String(u?.id || ''), 10)
const match = active.find((a) => parseInt(String(a?.id || ''), 10) === uid)
target = match?.id || active[0]?.id || accounts[0]?.id
}
if (target) {
const cur = getCurrentAccountId()
const next = parseInt(String(target), 10)
if (Number.isFinite(next) && next > 0 && cur !== next) setCurrentAccountId(next)
}
} catch (e) {
// ignore
}
} catch (e) { } catch (e) {
setMe(null) dispatch(setCurrentUser(null))
} finally { } finally {
setChecking(false) setChecking(false)
} }
@ -98,20 +78,15 @@ function App() {
} }
}, [showUserPopover]) }, [showUserPopover])
// effectiveUserId isAdmin
const isAdmin = (me?.role || '') === 'admin'
// //
const currentViewingUser = users.find((u) => parseInt(String(u?.id || ''), 10) === viewingUserId) const currentViewingUser = users.find((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)
const effectiveUserId = isAdmin && viewingUserId ? viewingUserId : parseInt(String(me?.id || ''), 10)
const handleSwitchUser = (userId) => { const handleSwitchUser = (userId) => {
const nextUserId = parseInt(String(userId || ''), 10) const nextUserId = parseInt(String(userId || ''), 10)
if (Number.isFinite(nextUserId) && nextUserId > 0) { if (Number.isFinite(nextUserId) && nextUserId > 0) {
setViewingUserId(nextUserId) dispatch(switchUser(nextUserId))
localStorage.setItem(VIEWING_USER_ID_KEY, String(nextUserId))
setShowUserPopover(false) setShowUserPopover(false)
// accountAccountSelector //
window.dispatchEvent(new CustomEvent('ats:user:switched', { detail: { userId: nextUserId } })) window.dispatchEvent(new CustomEvent('ats:user:switched', { detail: { userId: nextUserId } }))
} }
} }
@ -131,7 +106,7 @@ function App() {
) )
} }
if (!me) { if (!currentUser) {
return <Login onLoggedIn={refreshMe} /> return <Login onLoggedIn={refreshMe} />
} }
@ -142,7 +117,7 @@ function App() {
<div className="nav-container"> <div className="nav-container">
<div className="nav-left"> <div className="nav-left">
<h1 className="nav-title">自动交易系统</h1> <h1 className="nav-title">自动交易系统</h1>
<AccountSelector currentUser={me} viewingUserId={effectiveUserId} /> <AccountSelector />
</div> </div>
<div className="nav-links"> <div className="nav-links">
<Link to="/">仪表板</Link> <Link to="/">仪表板</Link>
@ -176,7 +151,7 @@ function App() {
gap: '4px' gap: '4px'
}} }}
> >
<span>👤 {currentViewingUser?.username || me?.username || '选择用户'}</span> <span>👤 {currentViewingUser?.username || currentUser?.username || '选择用户'}</span>
{currentViewingUser?.role === 'admin' ? '(管理员)' : ''} {currentViewingUser?.role === 'admin' ? '(管理员)' : ''}
<span></span> <span></span>
</button> </button>
@ -229,7 +204,7 @@ function App() {
</div> </div>
) : ( ) : (
<span className="nav-user-name"> <span className="nav-user-name">
{me?.username ? me.username : 'user'} {currentUser?.username ? currentUser.username : 'user'}
</span> </span>
)} )}
<button <button
@ -237,9 +212,9 @@ function App() {
className="nav-logout" className="nav-logout"
onClick={() => { onClick={() => {
clearAuthToken() clearAuthToken()
clearCurrentAccountId() dispatch(setCurrentUser(null))
localStorage.removeItem(VIEWING_USER_ID_KEY) dispatch(setViewingUserId(null))
setMe(null) dispatch(setUsers([]))
setChecking(false) setChecking(false)
}} }}
> >
@ -253,10 +228,10 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<StatsDashboard />} /> <Route path="/" element={<StatsDashboard />} />
<Route path="/recommendations" element={<Recommendations />} /> <Route path="/recommendations" element={<Recommendations />} />
<Route path="/config" element={<ConfigPanel currentUser={me} viewingUserId={effectiveUserId} />} /> <Route path="/config" element={<ConfigPanel />} />
<Route path="/config/guide" element={<ConfigGuide />} /> <Route path="/config/guide" element={<ConfigGuide />} />
<Route path="/trades" element={<TradeList />} /> <Route path="/trades" element={<TradeList />} />
<Route path="/global-config" element={isAdmin ? <GlobalConfig currentUser={me} /> : <div style={{ padding: '24px' }}>无权限</div>} /> <Route path="/global-config" element={isAdmin ? <GlobalConfig /> : <div style={{ padding: '24px' }}>无权限</div>} />
<Route path="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} /> <Route path="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} />
</Routes> </Routes>
</main> </main>

View File

@ -1,15 +1,26 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect } from 'react'
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api' import { useDispatch, useSelector } from 'react-redux'
import { api } from '../services/api'
import {
setAccountId,
setAccounts,
selectFirstActiveAccount,
selectAccountId,
selectAccounts,
selectCurrentUser,
selectViewingUserId,
selectIsAdmin,
selectEffectiveUserId,
} from '../store/appSlice'
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id' const AccountSelector = ({ onChanged }) => {
const dispatch = useDispatch()
const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUserId }) => { const accountId = useSelector(selectAccountId)
const [accounts, setAccounts] = useState([]) const accounts = useSelector(selectAccounts)
const [accountId, setAccountId] = useState(getCurrentAccountId()) const currentUser = useSelector(selectCurrentUser)
const isAdmin = (currentUser?.role || '') === 'admin' const viewingUserId = useSelector(selectViewingUserId)
const isAdmin = useSelector(selectIsAdmin)
// prop使user_id const effectiveUserId = useSelector(selectEffectiveUserId)
const effectiveUserId = propViewingUserId || parseInt(String(currentUser?.id || ''), 10)
// //
useEffect(() => { useEffect(() => {
@ -26,30 +37,18 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
role: item.role || 'viewer', role: item.role || 'viewer',
user_id: item.user_id user_id: item.user_id
})) }))
setAccounts(accountsList) dispatch(setAccounts(accountsList))
// activelocalStorage // active
const firstActive = accountsList.find((a) => String(a?.status || 'active') === 'active') || accountsList[0] if (accountsList.length > 0) {
if (firstActive) { dispatch(selectFirstActiveAccount())
const nextAccountId = parseInt(String(firstActive.id || ''), 10)
if (Number.isFinite(nextAccountId) && nextAccountId > 0) {
// localStorage
setCurrentAccountId(nextAccountId)
setAccountId(nextAccountId)
//
try {
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId: nextAccountId } }))
} catch (e) {
// ignore
}
}
} }
}) })
.catch(() => setAccounts([])) .catch(() => dispatch(setAccounts([])))
} }
} }
window.addEventListener('ats:user:switched', handleUserSwitched) window.addEventListener('ats:user:switched', handleUserSwitched)
return () => window.removeEventListener('ats:user:switched', handleUserSwitched) return () => window.removeEventListener('ats:user:switched', handleUserSwitched)
}, [isAdmin]) }, [isAdmin, dispatch])
// effectiveUserId // effectiveUserId
useEffect(() => { useEffect(() => {
@ -66,31 +65,19 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
role: item.role || 'viewer', role: item.role || 'viewer',
user_id: item.user_id user_id: item.user_id
})) }))
setAccounts(accountsList) dispatch(setAccounts(accountsList))
// activelocalStorage // active
const firstActive = accountsList.find((a) => String(a?.status || 'active') === 'active') || accountsList[0] if (accountsList.length > 0) {
if (firstActive) { dispatch(selectFirstActiveAccount())
const nextAccountId = parseInt(String(firstActive.id || ''), 10)
if (Number.isFinite(nextAccountId) && nextAccountId > 0) {
// localStorage
setCurrentAccountId(nextAccountId)
setAccountId(nextAccountId)
//
try {
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId: nextAccountId } }))
} catch (e) {
// ignore
}
}
} }
}) })
.catch(() => setAccounts([])) .catch(() => dispatch(setAccounts([])))
} else { } else {
// //
const load = () => { const load = () => {
api.getAccounts() api.getAccounts()
.then((list) => setAccounts(Array.isArray(list) ? list : [])) .then((list) => dispatch(setAccounts(Array.isArray(list) ? list : [])))
.catch(() => setAccounts([])) .catch(() => dispatch(setAccounts([])))
} }
load() load()
@ -100,18 +87,14 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
return () => window.removeEventListener('ats:accounts:updated', onUpdated) return () => window.removeEventListener('ats:accounts:updated', onUpdated)
} }
} }
}, [isAdmin, effectiveUserId]) }, [isAdmin, effectiveUserId, dispatch])
// accountId onChanged
useEffect(() => { useEffect(() => {
setCurrentAccountId(accountId) if (typeof onChanged === 'function') {
if (typeof onChanged === 'function') onChanged(accountId) onChanged(accountId)
try {
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId } }))
} catch (e) {
// ignore
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId, onChanged])
}, [accountId])
const list = Array.isArray(accounts) ? accounts : [] const list = Array.isArray(accounts) ? accounts : []
const options = (list.length ? list : [{ id: 1, name: 'default' }]).reduce((acc, cur) => { const options = (list.length ? list : [{ id: 1, name: 'default' }]).reduce((acc, cur) => {
@ -127,9 +110,10 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
if (!options.length) return if (!options.length) return
if (options.some((a) => a.id === accountId)) return if (options.some((a) => a.id === accountId)) return
const firstActive = options.find((a) => String(a?.status || 'active') === 'active') || options[0] const firstActive = options.find((a) => String(a?.status || 'active') === 'active') || options[0]
setAccountId(firstActive.id) if (firstActive) {
// eslint-disable-next-line react-hooks/exhaustive-deps dispatch(setAccountId(parseInt(String(firstActive.id || ''), 10)))
}, [optionsKey, accountId]) }
}, [optionsKey, accountId, dispatch])
return ( return (
<div className="nav-account"> <div className="nav-account">
@ -139,7 +123,7 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
value={accountId || ''} value={accountId || ''}
onChange={(e) => { onChange={(e) => {
const v = parseInt(e.target.value, 10) const v = parseInt(e.target.value, 10)
setAccountId(Number.isFinite(v) && v > 0 ? v : 1) dispatch(setAccountId(Number.isFinite(v) && v > 0 ? v : 1))
}} }}
title="切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局" title="切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局"
disabled={isAdmin && !effectiveUserId} disabled={isAdmin && !effectiveUserId}
@ -160,4 +144,3 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
} }
export default AccountSelector export default AccountSelector

View File

@ -1,9 +1,25 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api' import { useSelector, useDispatch } from 'react-redux'
import { api } from '../services/api'
import {
setAccountId,
selectAccountId,
selectCurrentUser,
selectViewingUserId,
selectIsAdmin,
selectEffectiveUserId,
} from '../store/appSlice'
import './ConfigPanel.css' import './ConfigPanel.css'
const ConfigPanel = ({ currentUser, viewingUserId }) => { const ConfigPanel = () => {
const dispatch = useDispatch()
const accountId = useSelector(selectAccountId) // Redux ID
const currentUser = useSelector(selectCurrentUser)
const viewingUserId = useSelector(selectViewingUserId)
const isAdmin = useSelector(selectIsAdmin)
const effectiveUserId = useSelector(selectEffectiveUserId)
const [configs, setConfigs] = useState({}) const [configs, setConfigs] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -17,11 +33,6 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
const [accountTradingErr, setAccountTradingErr] = useState('') const [accountTradingErr, setAccountTradingErr] = useState('')
const [currentAccountMeta, setCurrentAccountMeta] = useState(null) const [currentAccountMeta, setCurrentAccountMeta] = useState(null)
const [configMeta, setConfigMeta] = useState(null) const [configMeta, setConfigMeta] = useState(null)
//
const [accountId, setAccountId] = useState(getCurrentAccountId())
const isAdmin = (currentUser?.role || '') === 'admin'
const globalStrategyAccountId = parseInt(String(configMeta?.global_strategy_account_id || '1'), 10) || 1 const globalStrategyAccountId = parseInt(String(configMeta?.global_strategy_account_id || '1'), 10) || 1
const isGlobalStrategyAccount = isAdmin && accountId === globalStrategyAccountId const isGlobalStrategyAccount = isAdmin && accountId === globalStrategyAccountId
const loadConfigMeta = async () => { const loadConfigMeta = async () => {
@ -469,7 +480,6 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
// account meta // account meta
useEffect(() => { useEffect(() => {
setCurrentAccountId(accountId)
setMessage('') setMessage('')
setLoading(true) setLoading(true)
loadCurrentAccountMeta().then(() => { loadCurrentAccountMeta().then(() => {
@ -477,41 +487,7 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
checkFeasibility() checkFeasibility()
loadAccountTradingStatus() loadAccountTradingStatus()
}) })
}, [accountId]) }, [accountId]) // accountId
// setInterval
useEffect(() => {
const onChanged = (e) => {
const next = parseInt(String(e?.detail?.accountId || ''), 10)
if (Number.isFinite(next) && next > 0 && next !== accountId) {
setAccountId(next)
// account meta
loadCurrentAccountMeta().then(() => {
loadConfigs()
loadAccountTradingStatus()
})
}
}
window.addEventListener('ats:account:changed', onChanged)
return () => window.removeEventListener('ats:account:changed', onChanged)
}, [accountId])
// localStorage
useEffect(() => {
const timer = setInterval(() => {
const cur = getCurrentAccountId()
if (cur !== accountId) {
setAccountId(cur)
// account meta
loadCurrentAccountMeta().then(() => {
loadConfigs()
loadAccountTradingStatus()
})
}
}, 1000)
return () => clearInterval(timer)
}, [accountId])
const checkFeasibility = async () => { const checkFeasibility = async () => {
setCheckingFeasibility(true) setCheckingFeasibility(true)
@ -883,7 +859,7 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
type="button" type="button"
className="system-btn primary" className="system-btn primary"
onClick={() => { onClick={() => {
setCurrentAccountId(globalStrategyAccountId) dispatch(setAccountId(globalStrategyAccountId))
setAccountId(globalStrategyAccountId) setAccountId(globalStrategyAccountId)
setMessage('已切换到全局策略账号(策略核心统一在这里维护)') setMessage('已切换到全局策略账号(策略核心统一在这里维护)')
}} }}

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { api } from '../services/api' import { api } from '../services/api'
import { selectCurrentUser, selectIsAdmin } from '../store/appSlice'
import './GlobalConfig.css' import './GlobalConfig.css'
import './ConfigPanel.css' // ConfigPanel import './ConfigPanel.css' // ConfigPanel
@ -145,7 +147,10 @@ const ConfigItem = ({ label, config, onUpdate, disabled }) => {
) )
} }
const GlobalConfig = ({ currentUser }) => { const GlobalConfig = () => {
const currentUser = useSelector(selectCurrentUser)
const isAdmin = useSelector(selectIsAdmin)
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -178,8 +183,7 @@ const GlobalConfig = ({ currentUser }) => {
'ENTRY_MAX_DRIFT_PCT_RANGING', 'ENTRY_MAX_DRIFT_PCT_RANGING',
]) ])
// 使 isAdmin // isAdmin Redux
const isAdmin = (currentUser?.role || '') === 'admin'
// //
const presets = { const presets = {

View File

@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { api } from '../services/api' import { api } from '../services/api'
import './Recommendations.css' import './Recommendations.css'
import CollapsibleUserGuide from './CollapsibleUserGuide' import CollapsibleUserGuide from './CollapsibleUserGuide'
import { TrendingDown, TrendingUp } from 'lucide-react' import { TrendingDown, TrendingUp } from 'lucide-react'
import { selectAccountId } from '../store/appSlice'
function Recommendations() { function Recommendations() {
const accountId = useSelector(selectAccountId) // Redux ID
const [recommendations, setRecommendations] = useState([]) const [recommendations, setRecommendations] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -19,21 +22,9 @@ function Recommendations() {
const [bookmarking, setBookmarking] = useState({}) // ID const [bookmarking, setBookmarking] = useState({}) // ID
const [ordering, setOrdering] = useState({}) // ID const [ordering, setOrdering] = useState({}) // ID
// ID
const getCurrentAccountId = () => {
try {
const v = localStorage.getItem('ats_account_id');
const n = parseInt(v || '1', 10);
return Number.isFinite(n) && n > 0 ? n : 1;
} catch (e) {
return 1;
}
};
// //
const getDefaultOrderSize = () => { const getDefaultOrderSize = () => {
try { try {
const accountId = getCurrentAccountId();
const key = `ats_default_order_size_${accountId}`; const key = `ats_default_order_size_${accountId}`;
const value = localStorage.getItem(key); const value = localStorage.getItem(key);
if (value) { if (value) {
@ -51,7 +42,6 @@ function Recommendations() {
// //
const setDefaultOrderSize = (size) => { const setDefaultOrderSize = (size) => {
try { try {
const accountId = getCurrentAccountId();
const key = `ats_default_order_size_${accountId}`; const key = `ats_default_order_size_${accountId}`;
const sizeNum = parseFloat(String(size || '1')); const sizeNum = parseFloat(String(size || '1'));
if (Number.isFinite(sizeNum) && sizeNum > 0) { if (Number.isFinite(sizeNum) && sizeNum > 0) {
@ -64,7 +54,12 @@ function Recommendations() {
return false; return false;
}; };
const [defaultOrderSize, setDefaultOrderSizeState] = useState(getDefaultOrderSize()); const [defaultOrderSize, setDefaultOrderSizeState] = useState(getDefaultOrderSize())
// accountId
useEffect(() => {
setDefaultOrderSizeState(getDefaultOrderSize())
}, [accountId])
useEffect(() => { useEffect(() => {
loadRecommendations() loadRecommendations()
@ -378,7 +373,7 @@ function Recommendations() {
notionalUsdt, notionalUsdt,
leverage leverage
); );
alert(`下单成功!\n订单ID: ${result.order_id}\n交易ID: ${result.trade_id}`); alert(`下单成功!\n订单ID: ${result.order_id}\n交易ID: ${result.trade_id || 'N/A'}`);
// //
} catch (err) { } catch (err) {
alert(`下单失败: ${err.message}`); alert(`下单失败: ${err.message}`);

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { api } from '../services/api' import { api } from '../services/api'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { selectAccountId } from '../store/appSlice'
import './StatsDashboard.css' import './StatsDashboard.css'
const StatsDashboard = () => { const StatsDashboard = () => {
const accountId = useSelector(selectAccountId) // Redux ID
const [dashboardData, setDashboardData] = useState(null) const [dashboardData, setDashboardData] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [closingSymbol, setClosingSymbol] = useState(null) const [closingSymbol, setClosingSymbol] = useState(null)
@ -47,18 +50,10 @@ const StatsDashboard = () => {
loadTradingConfig() // loadTradingConfig() //
}, 30000) // 30 }, 30000) // 30
//
const handleAccountChange = () => {
loadDashboard()
loadTradingConfig()
}
window.addEventListener('ats:account:changed', handleAccountChange)
return () => { return () => {
clearInterval(interval) clearInterval(interval)
window.removeEventListener('ats:account:changed', handleAccountChange)
} }
}, []) }, [accountId]) // accountId
const loadTradingConfig = async () => { const loadTradingConfig = async () => {
try { try {

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { api } from '../services/api' import { api } from '../services/api'
import { selectAccountId } from '../store/appSlice'
import './TradeList.css' import './TradeList.css'
const TradeList = () => { const TradeList = () => {
const accountId = useSelector(selectAccountId) // Redux ID
const [trades, setTrades] = useState([]) const [trades, setTrades] = useState([])
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -19,17 +22,7 @@ const TradeList = () => {
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [accountId]) // accountId
//
const handleAccountChange = () => {
loadData()
}
window.addEventListener('ats:account:changed', handleAccountChange)
return () => {
window.removeEventListener('ats:account:changed', handleAccountChange)
}
}, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
@ -242,15 +235,10 @@ const TradeList = () => {
stats && ( stats && (
<div > <div >
<div style={{ fontSize: '1.1rem' }}>整体统计</div> <div style={{ fontSize: '1.1rem' }}>整体统计</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '4px' }}>
<div style={{ fontSize: '1.1rem' }}>总交易数{stats.total_trades} </div> <div style={{ fontSize: '1.1rem' }}>总交易数{stats.total_trades} </div>
<div style={{ fontSize: '1.1rem' }}>胜率{stats.win_rate.toFixed(2)}%</div> <div style={{ fontSize: '1.1rem' }}>胜率{stats.win_rate.toFixed(2)}%</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '4px' }}>
<div style={{ fontSize: '1.1rem' }}>总盈亏{stats.total_pnl.toFixed(2)} USDT</div> <div style={{ fontSize: '1.1rem' }}>总盈亏{stats.total_pnl.toFixed(2)} USDT</div>
<div style={{ fontSize: '1.1rem' }}>平均盈亏{stats.avg_pnl.toFixed(2)} USDT</div> <div style={{ fontSize: '1.1rem' }}>平均盈亏{stats.avg_pnl.toFixed(2)} USDT</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '4px' }}>
<div style={{ fontSize: '1.1rem' }}>平均持仓时长分钟{stats.avg_duration_minutes ? Number(stats.avg_duration_minutes).toFixed(0) : 0}</div> <div style={{ fontSize: '1.1rem' }}>平均持仓时长分钟{stats.avg_duration_minutes ? Number(stats.avg_duration_minutes).toFixed(0) : 0}</div>
<div style={{ fontSize: '1.1rem' }}>平仓原因有意义交易 <div style={{ fontSize: '1.1rem' }}>平仓原因有意义交易
<div style={{ fontSize: '1.1rem' }}> <div style={{ fontSize: '1.1rem' }}>
@ -272,9 +260,6 @@ const TradeList = () => {
return parts.length ? parts.join(' / ') : '—' return parts.length ? parts.join(' / ') : '—'
})()} })()}
</div> </div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '4px' }}>
<div style={{ fontSize: '1.1rem' }}>平均盈利 / 平均亏损期望 3:1{Number(stats.avg_win_loss_ratio || 0).toFixed(2)} : 1</div> <div style={{ fontSize: '1.1rem' }}>平均盈利 / 平均亏损期望 3:1{Number(stats.avg_win_loss_ratio || 0).toFixed(2)} : 1</div>
<div style={{ fontSize: '1.1rem' }}>总交易量名义{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT</div> <div style={{ fontSize: '1.1rem' }}>总交易量名义{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT</div>
</div> </div>

View File

@ -1,10 +1,14 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import App from './App' import App from './App'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> <Provider store={store}>
<App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@ -0,0 +1,147 @@
import { createSlice } from '@reduxjs/toolkit'
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
const ACCOUNT_ID_STORAGE_KEY = 'ats_account_id'
// 从 localStorage 读取初始值
const getInitialViewingUserId = () => {
try {
const stored = localStorage.getItem(VIEWING_USER_ID_KEY)
if (stored) {
const parsed = parseInt(stored, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
} catch (e) {
// ignore
}
return null
}
const getInitialAccountId = () => {
try {
const v = localStorage.getItem(ACCOUNT_ID_STORAGE_KEY)
const n = parseInt(v || '1', 10)
return Number.isFinite(n) && n > 0 ? n : 1
} catch (e) {
return 1
}
}
const initialState = {
currentUser: null, // 当前登录用户
viewingUserId: getInitialViewingUserId(), // 管理员查看的用户ID
accountId: getInitialAccountId(), // 当前选中的账号ID
accounts: [], // 当前用户的账号列表
users: [], // 用户列表(管理员可见)
}
const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setCurrentUser: (state, action) => {
state.currentUser = action.payload
},
setViewingUserId: (state, action) => {
const userId = action.payload
state.viewingUserId = userId
if (userId) {
try {
localStorage.setItem(VIEWING_USER_ID_KEY, String(userId))
} catch (e) {
// ignore
}
} else {
try {
localStorage.removeItem(VIEWING_USER_ID_KEY)
} catch (e) {
// ignore
}
}
},
setAccountId: (state, action) => {
const accountId = action.payload
state.accountId = accountId
if (accountId) {
try {
localStorage.setItem(ACCOUNT_ID_STORAGE_KEY, String(accountId))
} catch (e) {
// ignore
}
// 触发自定义事件,保持向后兼容
try {
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId } }))
} catch (e) {
// ignore
}
}
},
setAccounts: (state, action) => {
state.accounts = action.payload
},
setUsers: (state, action) => {
state.users = action.payload
},
// 切换用户时自动选择第一个active账号
switchUser: (state, action) => {
const userId = action.payload
state.viewingUserId = userId
if (userId) {
try {
localStorage.setItem(VIEWING_USER_ID_KEY, String(userId))
} catch (e) {
// ignore
}
}
// 账号列表会在组件中异步加载这里不自动切换accountId
// 等待账号列表加载完成后再切换
},
// 切换用户后账号列表加载完成自动选择第一个active账号
selectFirstActiveAccount: (state) => {
const firstActive = state.accounts.find((a) => String(a?.status || 'active') === 'active') || state.accounts[0]
if (firstActive) {
const nextAccountId = parseInt(String(firstActive.id || ''), 10)
if (Number.isFinite(nextAccountId) && nextAccountId > 0) {
state.accountId = nextAccountId
try {
localStorage.setItem(ACCOUNT_ID_STORAGE_KEY, String(nextAccountId))
} catch (e) {
// ignore
}
// 触发自定义事件
try {
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId: nextAccountId } }))
} catch (e) {
// ignore
}
}
}
},
},
})
export const {
setCurrentUser,
setViewingUserId,
setAccountId,
setAccounts,
setUsers,
switchUser,
selectFirstActiveAccount,
} = appSlice.actions
// Selectors
export const selectCurrentUser = (state) => state.app.currentUser
export const selectViewingUserId = (state) => state.app.viewingUserId
export const selectAccountId = (state) => state.app.accountId
export const selectAccounts = (state) => state.app.accounts
export const selectUsers = (state) => state.app.users
export const selectIsAdmin = (state) => (state.app.currentUser?.role || '') === 'admin'
export const selectEffectiveUserId = (state) => {
const isAdmin = (state.app.currentUser?.role || '') === 'admin'
return isAdmin && state.app.viewingUserId
? state.app.viewingUserId
: parseInt(String(state.app.currentUser?.id || ''), 10)
}
export default appSlice.reducer

View File

@ -0,0 +1,10 @@
import { configureStore } from '@reduxjs/toolkit'
import appSlice from './appSlice'
export const store = configureStore({
reducer: {
app: appSlice,
},
})
export default store