diff --git a/frontend/src/App.css b/frontend/src/App.css index f45d24f..94124a9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,6 +67,87 @@ color: #111; } +.nav-user-switcher { + position: relative; +} + +.nav-user-switch-btn { + padding: 0.45rem 0.7rem; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.25); + background: rgba(255,255,255,0.08); + color: #fff; + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.25rem; + outline: none; + transition: background-color 0.2s; +} + +.nav-user-switch-btn:hover { + background: rgba(255,255,255,0.15); +} + +.nav-user-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + min-width: 200px; + max-width: 300px; + z-index: 1000; + overflow: hidden; +} + +.popover-header { + padding: 12px 16px; + background: #f5f5f5; + border-bottom: 1px solid #e0e0e0; + font-weight: 600; + font-size: 14px; + color: #333; +} + +.popover-list { + max-height: 300px; + overflow-y: auto; +} + +.popover-item { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid #f0f0f0; +} + +.popover-item:hover { + background: #f5f5f5; +} + +.popover-item.active { + background: #e3f2fd; +} + +.popover-item:last-child { + border-bottom: none; +} + +.popover-item-name { + font-weight: 500; + font-size: 14px; + color: #333; + margin-bottom: 4px; +} + +.popover-item-meta { + font-size: 12px; + color: #666; +} + @media (min-width: 768px) { .nav-title { font-size: 1.5rem; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a37d68a..7278f4f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import StatsDashboard from './components/StatsDashboard' import Recommendations from './components/Recommendations' 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' import './App.css' @@ -88,7 +89,12 @@ function App() { 交易推荐 配置 交易记录 - {isAdmin ? 日志监控 : null} + {isAdmin ? ( + <> + 全局配置 + 日志监控 + + ) : null}
@@ -117,6 +123,7 @@ function App() { } /> } /> } /> + :
无权限
} /> :
无权限
} /> diff --git a/frontend/src/components/AccountSelector.jsx b/frontend/src/components/AccountSelector.jsx index 9c89c6b..089df53 100644 --- a/frontend/src/components/AccountSelector.jsx +++ b/frontend/src/components/AccountSelector.jsx @@ -1,12 +1,27 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState, useRef } from 'react' import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api' +const VIEWING_USER_ID_KEY = 'ats_viewing_user_id' + const AccountSelector = ({ onChanged, currentUser }) => { const [accounts, setAccounts] = useState([]) const [accountId, setAccountId] = useState(getCurrentAccountId()) const [users, setUsers] = useState([]) - const [selectedUserId, setSelectedUserId] = useState(null) + 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 parseInt(String(currentUser?.id || ''), 10) || null + }) + const [showUserPopover, setShowUserPopover] = useState(false) + const popoverRef = useRef(null) const isAdmin = (currentUser?.role || '') === 'admin' + + // 当前实际查看的用户(管理员切换后,或普通用户自己) + const effectiveUserId = isAdmin ? viewingUserId : parseInt(String(currentUser?.id || ''), 10) // 管理员:加载用户列表 useEffect(() => { @@ -14,61 +29,72 @@ const AccountSelector = ({ onChanged, currentUser }) => { api.getUsers() .then((list) => { setUsers(Array.isArray(list) ? list : []) - // 默认选择当前登录用户 + // 如果viewingUserId不在列表中,重置为当前登录用户 const currentUserId = parseInt(String(currentUser?.id || ''), 10) - if (currentUserId && list.some((u) => parseInt(String(u?.id || ''), 10) === currentUserId)) { - setSelectedUserId(currentUserId) - } else if (list.length > 0) { - setSelectedUserId(parseInt(String(list[0]?.id || ''), 10)) + if (viewingUserId && !list.some((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)) { + setViewingUserId(currentUserId) + localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId)) } }) .catch(() => setUsers([])) } - }, [isAdmin, currentUser?.id]) + }, [isAdmin, currentUser?.id, viewingUserId]) - // 管理员:根据选择的用户加载账号列表 + // 根据effectiveUserId加载账号列表(管理员查看指定用户,普通用户查看自己) useEffect(() => { - if (isAdmin && selectedUserId) { - api.getUserAccounts(selectedUserId) - .then((list) => { - // 转换数据格式:后端返回 {account_id, account_name, account_status},前端期望 {id, name, status} - const accountsList = (Array.isArray(list) ? list : []).map((item) => ({ - id: item.account_id || item.id, - name: item.account_name || item.name || '', - status: item.account_status || item.status || 'active', - role: item.role || 'viewer', - user_id: item.user_id - })) - setAccounts(accountsList) - // 自动选择第一个active账号 - 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) { - setAccountId(nextAccountId) + if (effectiveUserId) { + if (isAdmin) { + // 管理员:加载指定用户的账号列表 + api.getUserAccounts(effectiveUserId) + .then((list) => { + // 转换数据格式:后端返回 {account_id, account_name, account_status},前端期望 {id, name, status} + const accountsList = (Array.isArray(list) ? list : []).map((item) => ({ + id: item.account_id || item.id, + name: item.account_name || item.name || '', + status: item.account_status || item.status || 'active', + role: item.role || 'viewer', + user_id: item.user_id + })) + setAccounts(accountsList) + // 自动选择第一个active账号 + 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) { + setAccountId(nextAccountId) + } } - } - }) - .catch(() => setAccounts([])) - } - }, [isAdmin, selectedUserId]) - - // 普通用户:直接加载自己的账号列表 - useEffect(() => { - if (!isAdmin) { - const load = () => { - api.getAccounts() - .then((list) => setAccounts(Array.isArray(list) ? list : [])) + }) .catch(() => setAccounts([])) - } - load() + } else { + // 普通用户:直接加载自己的账号列表 + const load = () => { + api.getAccounts() + .then((list) => setAccounts(Array.isArray(list) ? list : [])) + .catch(() => setAccounts([])) + } + load() - // 配置页创建/更新账号后会触发该事件,用于即时刷新下拉列表 - const onUpdated = () => load() - window.addEventListener('ats:accounts:updated', onUpdated) - return () => window.removeEventListener('ats:accounts:updated', onUpdated) + // 配置页创建/更新账号后会触发该事件,用于即时刷新下拉列表 + const onUpdated = () => load() + window.addEventListener('ats:accounts:updated', onUpdated) + return () => window.removeEventListener('ats:accounts:updated', onUpdated) + } } - }, [isAdmin]) + }, [isAdmin, effectiveUserId]) + + // 点击外部关闭popover + useEffect(() => { + const handleClickOutside = (event) => { + if (popoverRef.current && !popoverRef.current.contains(event.target)) { + setShowUserPopover(false) + } + } + if (showUserPopover) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [showUserPopover]) useEffect(() => { setCurrentAccountId(accountId) @@ -99,29 +125,55 @@ const AccountSelector = ({ onChanged, currentUser }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [optionsKey, accountId]) + const currentViewingUser = users.find((u) => parseInt(String(u?.id || ''), 10) === viewingUserId) + + 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)) + setShowUserPopover(false) + // 切换用户时,重置账号选择 + setAccountId(null) + } + } + return (
{isAdmin ? ( - <> - 用户 - - + 👤 {currentViewingUser?.username || '选择用户'} + {currentViewingUser?.role === 'admin' ? '(管理员)' : ''} + + + {showUserPopover && ( +
+
切换用户视角
+
+ {users.map((u) => ( +
handleSwitchUser(u.id)} + > +
+ {u.username || 'user'} {u.role === 'admin' ? '(管理员)' : ''} +
+
+ {u.status === 'active' ? '启用' : '禁用'} +
+
+ ))} +
+
+ )} +
) : null} 账号 setNewUser({ ...newUser, username: e.target.value })} + placeholder="输入用户名" + /> +
+
+ + setNewUser({ ...newUser, password: e.target.value })} + placeholder="输入密码" + /> +
+
+ + +
+
+ + +
+
+ + +
+ + )} + +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
ID用户名角色状态操作
{user.id}{user.username} + + + + + {editingUserId === user.id ? ( +
+ { + if (e.key === 'Enter') { + handleUpdateUserPassword(user.id) + } + }} + /> + + +
+ ) : ( + + )} +
+
+ + + {/* 账号管理 */} +
+
+

账号管理

+ +
+ +
+ + + + + + + + + + + + {accounts.map((account) => ( + + + + + + + + ))} + +
ID名称状态测试网API Key
{account.id}{account.name || '未命名'} + + {account.status === 'active' ? '启用' : '禁用'} + + {account.use_testnet ? '是' : '否'}{account.has_api_key ? '已配置' : '未配置'}
+
+
+ + ) +} + +export default GlobalConfig diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index ee2e662..3b5b63a 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -630,4 +630,60 @@ export const api = { } return response.json(); }, + + // 管理员:创建用户 + createUser: async (data) => { + const response = await fetch(buildUrl('/api/admin/users'), { + method: 'POST', + headers: withAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '创建用户失败' })); + throw new Error(error.detail || '创建用户失败'); + } + return response.json(); + }, + + // 管理员:更新用户密码 + updateUserPassword: async (userId, password) => { + const response = await fetch(buildUrl(`/api/admin/users/${userId}/password`), { + method: 'PUT', + headers: withAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ password }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '更新密码失败' })); + throw new Error(error.detail || '更新密码失败'); + } + return response.json(); + }, + + // 管理员:更新用户角色 + updateUserRole: async (userId, role) => { + const response = await fetch(buildUrl(`/api/admin/users/${userId}/role`), { + method: 'PUT', + headers: withAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ role }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '更新角色失败' })); + throw new Error(error.detail || '更新角色失败'); + } + return response.json(); + }, + + // 管理员:更新用户状态 + updateUserStatus: async (userId, status) => { + const response = await fetch(buildUrl(`/api/admin/users/${userId}/status`), { + method: 'PUT', + headers: withAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '更新状态失败' })); + throw new Error(error.detail || '更新状态失败'); + } + return response.json(); + }, };