a
This commit is contained in:
parent
d6cdc2f055
commit
d051be3f65
97
frontend/REDUX_SETUP.md
Normal file
97
frontend/REDUX_SETUP.md
Normal 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 管理用户和账号状态,切换用户/账号时所有页面会自动同步更新。
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import ConfigPanel from './components/ConfigPanel'
|
||||
import ConfigGuide from './components/ConfigGuide'
|
||||
import TradeList from './components/TradeList'
|
||||
|
|
@ -9,77 +10,56 @@ import LogMonitor from './components/LogMonitor'
|
|||
import AccountSelector from './components/AccountSelector'
|
||||
import GlobalConfig from './components/GlobalConfig'
|
||||
import Login from './components/Login'
|
||||
import { api, clearAuthToken, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api'
|
||||
|
||||
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
|
||||
import { api, clearAuthToken } from './services/api'
|
||||
import {
|
||||
setCurrentUser,
|
||||
setViewingUserId,
|
||||
setUsers,
|
||||
switchUser,
|
||||
selectCurrentUser,
|
||||
selectViewingUserId,
|
||||
selectUsers,
|
||||
selectIsAdmin,
|
||||
selectEffectiveUserId,
|
||||
} from './store/appSlice'
|
||||
import './App.css'
|
||||
|
||||
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 [users, setUsers] = useState([])
|
||||
const [viewingUserId, setViewingUserId] = useState(() => {
|
||||
// 管理员:从localStorage读取当前查看的用户ID,默认是当前登录用户
|
||||
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 userPopoverRef = React.useRef(null)
|
||||
|
||||
const refreshMe = async () => {
|
||||
try {
|
||||
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 {
|
||||
const userList = await api.getUsers()
|
||||
setUsers(Array.isArray(userList) ? userList : [])
|
||||
const usersArray = Array.isArray(userList) ? userList : []
|
||||
dispatch(setUsers(usersArray))
|
||||
// 如果viewingUserId未设置或不在列表中,设置为当前登录用户
|
||||
const currentUserId = parseInt(String(u?.id || ''), 10)
|
||||
if (!viewingUserId || !userList.some((user) => parseInt(String(user?.id || ''), 10) === viewingUserId)) {
|
||||
setViewingUserId(currentUserId)
|
||||
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId))
|
||||
if (!viewingUserId || !usersArray.some((user) => parseInt(String(user?.id || ''), 10) === viewingUserId)) {
|
||||
dispatch(setViewingUserId(currentUserId))
|
||||
}
|
||||
} catch (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) {
|
||||
setMe(null)
|
||||
dispatch(setCurrentUser(null))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
|
|
@ -98,20 +78,15 @@ function App() {
|
|||
}
|
||||
}, [showUserPopover])
|
||||
|
||||
// 必须在定义 effectiveUserId 之前定义 isAdmin
|
||||
const isAdmin = (me?.role || '') === 'admin'
|
||||
|
||||
// 当前查看的用户信息
|
||||
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 nextUserId = parseInt(String(userId || ''), 10)
|
||||
if (Number.isFinite(nextUserId) && nextUserId > 0) {
|
||||
setViewingUserId(nextUserId)
|
||||
localStorage.setItem(VIEWING_USER_ID_KEY, String(nextUserId))
|
||||
dispatch(switchUser(nextUserId))
|
||||
setShowUserPopover(false)
|
||||
// 切换用户后,触发account变化事件,让AccountSelector重新加载该用户的账号列表
|
||||
// 触发自定义事件,保持向后兼容
|
||||
window.dispatchEvent(new CustomEvent('ats:user:switched', { detail: { userId: nextUserId } }))
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +106,7 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
if (!currentUser) {
|
||||
return <Login onLoggedIn={refreshMe} />
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +117,7 @@ function App() {
|
|||
<div className="nav-container">
|
||||
<div className="nav-left">
|
||||
<h1 className="nav-title">自动交易系统</h1>
|
||||
<AccountSelector currentUser={me} viewingUserId={effectiveUserId} />
|
||||
<AccountSelector />
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<Link to="/">仪表板</Link>
|
||||
|
|
@ -176,7 +151,7 @@ function App() {
|
|||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<span>👤 {currentViewingUser?.username || me?.username || '选择用户'}</span>
|
||||
<span>👤 {currentViewingUser?.username || currentUser?.username || '选择用户'}</span>
|
||||
{currentViewingUser?.role === 'admin' ? '(管理员)' : ''}
|
||||
<span>▼</span>
|
||||
</button>
|
||||
|
|
@ -229,7 +204,7 @@ function App() {
|
|||
</div>
|
||||
) : (
|
||||
<span className="nav-user-name">
|
||||
{me?.username ? me.username : 'user'}
|
||||
{currentUser?.username ? currentUser.username : 'user'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -237,9 +212,9 @@ function App() {
|
|||
className="nav-logout"
|
||||
onClick={() => {
|
||||
clearAuthToken()
|
||||
clearCurrentAccountId()
|
||||
localStorage.removeItem(VIEWING_USER_ID_KEY)
|
||||
setMe(null)
|
||||
dispatch(setCurrentUser(null))
|
||||
dispatch(setViewingUserId(null))
|
||||
dispatch(setUsers([]))
|
||||
setChecking(false)
|
||||
}}
|
||||
>
|
||||
|
|
@ -253,10 +228,10 @@ function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<StatsDashboard />} />
|
||||
<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="/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>} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
||||
import React, { useEffect } from 'react'
|
||||
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, currentUser, viewingUserId: propViewingUserId }) => {
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
||||
const isAdmin = (currentUser?.role || '') === 'admin'
|
||||
|
||||
// 当前实际查看的用户(管理员通过prop传入,普通用户使用自己的user_id)
|
||||
const effectiveUserId = propViewingUserId || parseInt(String(currentUser?.id || ''), 10)
|
||||
const AccountSelector = ({ onChanged }) => {
|
||||
const dispatch = useDispatch()
|
||||
const accountId = useSelector(selectAccountId)
|
||||
const accounts = useSelector(selectAccounts)
|
||||
const currentUser = useSelector(selectCurrentUser)
|
||||
const viewingUserId = useSelector(selectViewingUserId)
|
||||
const isAdmin = useSelector(selectIsAdmin)
|
||||
const effectiveUserId = useSelector(selectEffectiveUserId)
|
||||
|
||||
// 监听用户切换事件(管理员切换用户时触发)
|
||||
useEffect(() => {
|
||||
|
|
@ -26,30 +37,18 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
|
|||
role: item.role || 'viewer',
|
||||
user_id: item.user_id
|
||||
}))
|
||||
setAccounts(accountsList)
|
||||
// 自动选择第一个active账号,并立即同步到localStorage和触发事件
|
||||
const firstActive = accountsList.find((a) => String(a?.status || 'active') === 'active') || accountsList[0]
|
||||
if (firstActive) {
|
||||
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
|
||||
}
|
||||
}
|
||||
dispatch(setAccounts(accountsList))
|
||||
// 自动选择第一个active账号
|
||||
if (accountsList.length > 0) {
|
||||
dispatch(selectFirstActiveAccount())
|
||||
}
|
||||
})
|
||||
.catch(() => setAccounts([]))
|
||||
.catch(() => dispatch(setAccounts([])))
|
||||
}
|
||||
}
|
||||
window.addEventListener('ats:user:switched', handleUserSwitched)
|
||||
return () => window.removeEventListener('ats:user:switched', handleUserSwitched)
|
||||
}, [isAdmin])
|
||||
}, [isAdmin, dispatch])
|
||||
|
||||
// 根据effectiveUserId加载账号列表(管理员查看指定用户,普通用户查看自己)
|
||||
useEffect(() => {
|
||||
|
|
@ -66,31 +65,19 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
|
|||
role: item.role || 'viewer',
|
||||
user_id: item.user_id
|
||||
}))
|
||||
setAccounts(accountsList)
|
||||
// 自动选择第一个active账号,并立即同步到localStorage和触发事件
|
||||
const firstActive = accountsList.find((a) => String(a?.status || 'active') === 'active') || accountsList[0]
|
||||
if (firstActive) {
|
||||
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
|
||||
}
|
||||
}
|
||||
dispatch(setAccounts(accountsList))
|
||||
// 自动选择第一个active账号
|
||||
if (accountsList.length > 0) {
|
||||
dispatch(selectFirstActiveAccount())
|
||||
}
|
||||
})
|
||||
.catch(() => setAccounts([]))
|
||||
.catch(() => dispatch(setAccounts([])))
|
||||
} else {
|
||||
// 普通用户:直接加载自己的账号列表
|
||||
const load = () => {
|
||||
api.getAccounts()
|
||||
.then((list) => setAccounts(Array.isArray(list) ? list : []))
|
||||
.catch(() => setAccounts([]))
|
||||
.then((list) => dispatch(setAccounts(Array.isArray(list) ? list : [])))
|
||||
.catch(() => dispatch(setAccounts([])))
|
||||
}
|
||||
load()
|
||||
|
||||
|
|
@ -100,18 +87,14 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
|
|||
return () => window.removeEventListener('ats:accounts:updated', onUpdated)
|
||||
}
|
||||
}
|
||||
}, [isAdmin, effectiveUserId])
|
||||
}, [isAdmin, effectiveUserId, dispatch])
|
||||
|
||||
// 当 accountId 变化时,调用 onChanged 回调
|
||||
useEffect(() => {
|
||||
setCurrentAccountId(accountId)
|
||||
if (typeof onChanged === 'function') onChanged(accountId)
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ats:account:changed', { detail: { accountId } }))
|
||||
} catch (e) {
|
||||
// ignore
|
||||
if (typeof onChanged === 'function') {
|
||||
onChanged(accountId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId])
|
||||
}, [accountId, onChanged])
|
||||
|
||||
const list = Array.isArray(accounts) ? accounts : []
|
||||
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.some((a) => a.id === accountId)) return
|
||||
const firstActive = options.find((a) => String(a?.status || 'active') === 'active') || options[0]
|
||||
setAccountId(firstActive.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [optionsKey, accountId])
|
||||
if (firstActive) {
|
||||
dispatch(setAccountId(parseInt(String(firstActive.id || ''), 10)))
|
||||
}
|
||||
}, [optionsKey, accountId, dispatch])
|
||||
|
||||
return (
|
||||
<div className="nav-account">
|
||||
|
|
@ -139,7 +123,7 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
|
|||
value={accountId || ''}
|
||||
onChange={(e) => {
|
||||
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="切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局"
|
||||
disabled={isAdmin && !effectiveUserId}
|
||||
|
|
@ -160,4 +144,3 @@ const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUse
|
|||
}
|
||||
|
||||
export default AccountSelector
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,25 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
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'
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -17,11 +33,6 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
|
|||
const [accountTradingErr, setAccountTradingErr] = useState('')
|
||||
const [currentAccountMeta, setCurrentAccountMeta] = 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 isGlobalStrategyAccount = isAdmin && accountId === globalStrategyAccountId
|
||||
const loadConfigMeta = async () => {
|
||||
|
|
@ -469,7 +480,6 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
|
|||
|
||||
// 切换账号时,刷新页面数据(先加载account meta,再加载其他)
|
||||
useEffect(() => {
|
||||
setCurrentAccountId(accountId)
|
||||
setMessage('')
|
||||
setLoading(true)
|
||||
loadCurrentAccountMeta().then(() => {
|
||||
|
|
@ -477,41 +487,7 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
|
|||
checkFeasibility()
|
||||
loadAccountTradingStatus()
|
||||
})
|
||||
}, [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])
|
||||
}, [accountId]) // 当 accountId 变化时重新加载
|
||||
|
||||
const checkFeasibility = async () => {
|
||||
setCheckingFeasibility(true)
|
||||
|
|
@ -883,7 +859,7 @@ const ConfigPanel = ({ currentUser, viewingUserId }) => {
|
|||
type="button"
|
||||
className="system-btn primary"
|
||||
onClick={() => {
|
||||
setCurrentAccountId(globalStrategyAccountId)
|
||||
dispatch(setAccountId(globalStrategyAccountId))
|
||||
setAccountId(globalStrategyAccountId)
|
||||
setMessage('已切换到全局策略账号(策略核心统一在这里维护)')
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { api } from '../services/api'
|
||||
import { selectCurrentUser, selectIsAdmin } from '../store/appSlice'
|
||||
import './GlobalConfig.css'
|
||||
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 [accounts, setAccounts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -178,8 +183,7 @@ const GlobalConfig = ({ currentUser }) => {
|
|||
'ENTRY_MAX_DRIFT_PCT_RANGING',
|
||||
])
|
||||
|
||||
// 必须在所有使用之前定义 isAdmin
|
||||
const isAdmin = (currentUser?.role || '') === 'admin'
|
||||
// isAdmin 已从 Redux 获取,无需重复定义
|
||||
|
||||
// 预设方案配置(必须在函数定义之前,常量定义)
|
||||
const presets = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { api } from '../services/api'
|
||||
import './Recommendations.css'
|
||||
import CollapsibleUserGuide from './CollapsibleUserGuide'
|
||||
import { TrendingDown, TrendingUp } from 'lucide-react'
|
||||
import { selectAccountId } from '../store/appSlice'
|
||||
|
||||
|
||||
|
||||
|
||||
function Recommendations() {
|
||||
const accountId = useSelector(selectAccountId) // 从 Redux 获取当前账号ID
|
||||
const [recommendations, setRecommendations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
|
@ -19,21 +22,9 @@ function Recommendations() {
|
|||
const [bookmarking, setBookmarking] = 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 = () => {
|
||||
try {
|
||||
const accountId = getCurrentAccountId();
|
||||
const key = `ats_default_order_size_${accountId}`;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) {
|
||||
|
|
@ -51,7 +42,6 @@ function Recommendations() {
|
|||
// 设置默认下单量(按账号存储)
|
||||
const setDefaultOrderSize = (size) => {
|
||||
try {
|
||||
const accountId = getCurrentAccountId();
|
||||
const key = `ats_default_order_size_${accountId}`;
|
||||
const sizeNum = parseFloat(String(size || '1'));
|
||||
if (Number.isFinite(sizeNum) && sizeNum > 0) {
|
||||
|
|
@ -64,7 +54,12 @@ function Recommendations() {
|
|||
return false;
|
||||
};
|
||||
|
||||
const [defaultOrderSize, setDefaultOrderSizeState] = useState(getDefaultOrderSize());
|
||||
const [defaultOrderSize, setDefaultOrderSizeState] = useState(getDefaultOrderSize())
|
||||
|
||||
// 当 accountId 变化时,重新获取默认下单量
|
||||
useEffect(() => {
|
||||
setDefaultOrderSizeState(getDefaultOrderSize())
|
||||
}, [accountId])
|
||||
|
||||
useEffect(() => {
|
||||
loadRecommendations()
|
||||
|
|
@ -378,7 +373,7 @@ function Recommendations() {
|
|||
notionalUsdt,
|
||||
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) {
|
||||
alert(`下单失败: ${err.message}`);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { api } from '../services/api'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { selectAccountId } from '../store/appSlice'
|
||||
import './StatsDashboard.css'
|
||||
|
||||
const StatsDashboard = () => {
|
||||
const accountId = useSelector(selectAccountId) // 从 Redux 获取当前账号ID
|
||||
const [dashboardData, setDashboardData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [closingSymbol, setClosingSymbol] = useState(null)
|
||||
|
|
@ -47,18 +50,10 @@ const StatsDashboard = () => {
|
|||
loadTradingConfig() // 同时刷新配置
|
||||
}, 30000) // 每30秒刷新
|
||||
|
||||
// 监听账号切换事件,自动重新加载数据
|
||||
const handleAccountChange = () => {
|
||||
loadDashboard()
|
||||
loadTradingConfig()
|
||||
}
|
||||
window.addEventListener('ats:account:changed', handleAccountChange)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
window.removeEventListener('ats:account:changed', handleAccountChange)
|
||||
}
|
||||
}, [])
|
||||
}, [accountId]) // 当 accountId 变化时重新加载
|
||||
|
||||
const loadTradingConfig = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { api } from '../services/api'
|
||||
import { selectAccountId } from '../store/appSlice'
|
||||
import './TradeList.css'
|
||||
|
||||
const TradeList = () => {
|
||||
const accountId = useSelector(selectAccountId) // 从 Redux 获取当前账号ID
|
||||
const [trades, setTrades] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -19,17 +22,7 @@ const TradeList = () => {
|
|||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
|
||||
// 监听账号切换事件,自动重新加载数据
|
||||
const handleAccountChange = () => {
|
||||
loadData()
|
||||
}
|
||||
window.addEventListener('ats:account:changed', handleAccountChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('ats:account:changed', handleAccountChange)
|
||||
}
|
||||
}, [])
|
||||
}, [accountId]) // 当 accountId 变化时重新加载
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -242,15 +235,10 @@ const TradeList = () => {
|
|||
stats && (
|
||||
<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.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.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' }}>平仓原因(有意义交易):
|
||||
<div style={{ fontSize: '1.1rem' }}>
|
||||
|
|
@ -272,9 +260,6 @@ const TradeList = () => {
|
|||
return parts.length ? parts.join(' / ') : '—'
|
||||
})()}
|
||||
</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' }}>总交易量(名义):{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from './store'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
|
|||
147
frontend/src/store/appSlice.js
Normal file
147
frontend/src/store/appSlice.js
Normal 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
|
||||
10
frontend/src/store/index.js
Normal file
10
frontend/src/store/index.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import appSlice from './appSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
app: appSlice,
|
||||
},
|
||||
})
|
||||
|
||||
export default store
|
||||
Loading…
Reference in New Issue
Block a user