a
This commit is contained in:
parent
352d36e7a5
commit
28bce8f02b
|
|
@ -67,6 +67,87 @@
|
||||||
color: #111;
|
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) {
|
@media (min-width: 768px) {
|
||||||
.nav-title {
|
.nav-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import StatsDashboard from './components/StatsDashboard'
|
||||||
import Recommendations from './components/Recommendations'
|
import Recommendations from './components/Recommendations'
|
||||||
import LogMonitor from './components/LogMonitor'
|
import LogMonitor from './components/LogMonitor'
|
||||||
import AccountSelector from './components/AccountSelector'
|
import AccountSelector from './components/AccountSelector'
|
||||||
|
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, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
@ -88,7 +89,12 @@ function App() {
|
||||||
<Link to="/recommendations">交易推荐</Link>
|
<Link to="/recommendations">交易推荐</Link>
|
||||||
<Link to="/config">配置</Link>
|
<Link to="/config">配置</Link>
|
||||||
<Link to="/trades">交易记录</Link>
|
<Link to="/trades">交易记录</Link>
|
||||||
{isAdmin ? <Link to="/logs">日志监控</Link> : null}
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
|
<Link to="/global-config">全局配置</Link>
|
||||||
|
<Link to="/logs">日志监控</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-user">
|
<div className="nav-user">
|
||||||
<span className="nav-user-name">
|
<span className="nav-user-name">
|
||||||
|
|
@ -117,6 +123,7 @@ function App() {
|
||||||
<Route path="/config" element={<ConfigPanel currentUser={me} />} />
|
<Route path="/config" element={<ConfigPanel currentUser={me} />} />
|
||||||
<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="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
<Route path="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,51 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
||||||
|
|
||||||
|
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
|
||||||
|
|
||||||
const AccountSelector = ({ onChanged, currentUser }) => {
|
const AccountSelector = ({ onChanged, currentUser }) => {
|
||||||
const [accounts, setAccounts] = useState([])
|
const [accounts, setAccounts] = useState([])
|
||||||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
||||||
const [users, setUsers] = useState([])
|
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 isAdmin = (currentUser?.role || '') === 'admin'
|
||||||
|
|
||||||
|
// 当前实际查看的用户(管理员切换后,或普通用户自己)
|
||||||
|
const effectiveUserId = isAdmin ? viewingUserId : parseInt(String(currentUser?.id || ''), 10)
|
||||||
|
|
||||||
// 管理员:加载用户列表
|
// 管理员:加载用户列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
api.getUsers()
|
api.getUsers()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
setUsers(Array.isArray(list) ? list : [])
|
setUsers(Array.isArray(list) ? list : [])
|
||||||
// 默认选择当前登录用户
|
// 如果viewingUserId不在列表中,重置为当前登录用户
|
||||||
const currentUserId = parseInt(String(currentUser?.id || ''), 10)
|
const currentUserId = parseInt(String(currentUser?.id || ''), 10)
|
||||||
if (currentUserId && list.some((u) => parseInt(String(u?.id || ''), 10) === currentUserId)) {
|
if (viewingUserId && !list.some((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)) {
|
||||||
setSelectedUserId(currentUserId)
|
setViewingUserId(currentUserId)
|
||||||
} else if (list.length > 0) {
|
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId))
|
||||||
setSelectedUserId(parseInt(String(list[0]?.id || ''), 10))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setUsers([]))
|
.catch(() => setUsers([]))
|
||||||
}
|
}
|
||||||
}, [isAdmin, currentUser?.id])
|
}, [isAdmin, currentUser?.id, viewingUserId])
|
||||||
|
|
||||||
// 管理员:根据选择的用户加载账号列表
|
// 根据effectiveUserId加载账号列表(管理员查看指定用户,普通用户查看自己)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin && selectedUserId) {
|
if (effectiveUserId) {
|
||||||
api.getUserAccounts(selectedUserId)
|
if (isAdmin) {
|
||||||
|
// 管理员:加载指定用户的账号列表
|
||||||
|
api.getUserAccounts(effectiveUserId)
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
// 转换数据格式:后端返回 {account_id, account_name, account_status},前端期望 {id, name, status}
|
// 转换数据格式:后端返回 {account_id, account_name, account_status},前端期望 {id, name, status}
|
||||||
const accountsList = (Array.isArray(list) ? list : []).map((item) => ({
|
const accountsList = (Array.isArray(list) ? list : []).map((item) => ({
|
||||||
|
|
@ -50,12 +66,8 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setAccounts([]))
|
.catch(() => setAccounts([]))
|
||||||
}
|
} else {
|
||||||
}, [isAdmin, selectedUserId])
|
|
||||||
|
|
||||||
// 普通用户:直接加载自己的账号列表
|
// 普通用户:直接加载自己的账号列表
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) {
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
api.getAccounts()
|
api.getAccounts()
|
||||||
.then((list) => setAccounts(Array.isArray(list) ? list : []))
|
.then((list) => setAccounts(Array.isArray(list) ? list : []))
|
||||||
|
|
@ -68,7 +80,21 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
||||||
window.addEventListener('ats:accounts:updated', onUpdated)
|
window.addEventListener('ats:accounts:updated', onUpdated)
|
||||||
return () => window.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
setCurrentAccountId(accountId)
|
setCurrentAccountId(accountId)
|
||||||
|
|
@ -99,29 +125,55 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [optionsKey, accountId])
|
}, [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 (
|
return (
|
||||||
<div className="nav-account">
|
<div className="nav-account">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<>
|
<div className="nav-user-switcher" ref={popoverRef} style={{ position: 'relative' }}>
|
||||||
<span className="nav-account-label">用户</span>
|
<button
|
||||||
<select
|
type="button"
|
||||||
className="nav-account-select"
|
className="nav-user-switch-btn"
|
||||||
value={selectedUserId || ''}
|
onClick={() => setShowUserPopover(!showUserPopover)}
|
||||||
onChange={(e) => {
|
title="切换查看的用户视角"
|
||||||
const v = parseInt(e.target.value, 10)
|
|
||||||
setSelectedUserId(Number.isFinite(v) && v > 0 ? v : null)
|
|
||||||
// 切换用户时,重置账号选择
|
|
||||||
setAccountId(null)
|
|
||||||
}}
|
|
||||||
title="管理员:先选择用户,再查看该用户下的账号"
|
|
||||||
>
|
>
|
||||||
|
👤 {currentViewingUser?.username || '选择用户'}
|
||||||
|
{currentViewingUser?.role === 'admin' ? '(管理员)' : ''}
|
||||||
|
<span style={{ marginLeft: '4px' }}>▼</span>
|
||||||
|
</button>
|
||||||
|
{showUserPopover && (
|
||||||
|
<div className="nav-user-popover">
|
||||||
|
<div className="popover-header">切换用户视角</div>
|
||||||
|
<div className="popover-list">
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<option key={u.id} value={u.id}>
|
<div
|
||||||
|
key={u.id}
|
||||||
|
className={`popover-item ${viewingUserId === u.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSwitchUser(u.id)}
|
||||||
|
>
|
||||||
|
<div className="popover-item-name">
|
||||||
{u.username || 'user'} {u.role === 'admin' ? '(管理员)' : ''}
|
{u.username || 'user'} {u.role === 'admin' ? '(管理员)' : ''}
|
||||||
</option>
|
</div>
|
||||||
|
<div className="popover-item-meta">
|
||||||
|
{u.status === 'active' ? '启用' : '禁用'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="nav-account-label">账号</span>
|
<span className="nav-account-label">账号</span>
|
||||||
<select
|
<select
|
||||||
|
|
@ -131,11 +183,11 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
||||||
const v = parseInt(e.target.value, 10)
|
const v = parseInt(e.target.value, 10)
|
||||||
setAccountId(Number.isFinite(v) && v > 0 ? v : 1)
|
setAccountId(Number.isFinite(v) && v > 0 ? v : 1)
|
||||||
}}
|
}}
|
||||||
title={isAdmin ? "切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局" : "切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局"}
|
title="切换账号后:配置/持仓/交易记录/统计会按账号隔离;推荐仍是全局"
|
||||||
disabled={isAdmin && !selectedUserId}
|
disabled={isAdmin && !effectiveUserId}
|
||||||
>
|
>
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<option value="">{isAdmin && !selectedUserId ? '请先选择用户' : '暂无账号'}</option>
|
<option value="">{isAdmin && !effectiveUserId ? '请先选择用户' : '暂无账号'}</option>
|
||||||
) : (
|
) : (
|
||||||
options.map((a) => (
|
options.map((a) => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
|
|
|
||||||
|
|
@ -855,7 +855,7 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
<div className="account-switch">
|
<div className="account-switch">
|
||||||
<span className="account-hint">当前账号:#{accountId}(在顶部导航切换)</span>
|
<span className="account-hint">当前账号:#{accountId}(在顶部导航切换)</span>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin ? (
|
{isAdmin && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||||
<div
|
<div
|
||||||
className="system-hint"
|
className="system-hint"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1407,7 +1407,7 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
|
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
|
||||||
{isAdmin && isGlobalStrategyAccount ? (
|
{isAdmin && isGlobalStrategyAccount && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||||
<div className="preset-section">
|
<div className="preset-section">
|
||||||
<div className="preset-header">
|
<div className="preset-header">
|
||||||
<h3>快速切换方案</h3>
|
<h3>快速切换方案</h3>
|
||||||
|
|
@ -1638,7 +1638,9 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.entries(configCategories).map(([category, label]) => (
|
{/* 配置项(禁用account时不显示) */}
|
||||||
|
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||||
|
Object.entries(configCategories).map(([category, label]) => (
|
||||||
<section key={category} className="config-section">
|
<section key={category} className="config-section">
|
||||||
<h3>{label}</h3>
|
<h3>{label}</h3>
|
||||||
<div className="config-grid">
|
<div className="config-grid">
|
||||||
|
|
@ -1660,7 +1662,12 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))
|
||||||
|
) : currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'disabled' ? (
|
||||||
|
<div className="system-hint" style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||||
|
当前账号已禁用,无法查看和修改配置
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showSnapshot && (
|
{showSnapshot && (
|
||||||
<div className="snapshot-modal-overlay" onClick={() => setShowSnapshot(false)} role="presentation">
|
<div className="snapshot-modal-overlay" onClick={() => setShowSnapshot(false)} role="presentation">
|
||||||
|
|
|
||||||
167
frontend/src/components/GlobalConfig.css
Normal file
167
frontend/src/components/GlobalConfig.css
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
.global-config {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-config-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-config-header h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-config-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #1976d2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.disabled {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
328
frontend/src/components/GlobalConfig.jsx
Normal file
328
frontend/src/components/GlobalConfig.jsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import './GlobalConfig.css'
|
||||||
|
|
||||||
|
const GlobalConfig = ({ currentUser }) => {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [accounts, setAccounts] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState(null)
|
||||||
|
const [showUserForm, setShowUserForm] = useState(false)
|
||||||
|
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
const [editingUserId, setEditingUserId] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
loadAccounts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.getUsers()
|
||||||
|
setUsers(Array.isArray(list) ? list : [])
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('加载用户列表失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.getAccounts()
|
||||||
|
setAccounts(Array.isArray(list) ? list : [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载账号列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
if (!newUser.username || !newUser.password) {
|
||||||
|
setMessage('用户名和密码不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.createUser(newUser)
|
||||||
|
setMessage('用户创建成功')
|
||||||
|
setShowUserForm(false)
|
||||||
|
setNewUser({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('创建用户失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateUserPassword = async (userId) => {
|
||||||
|
const passwordInput = document.querySelector(`input[data-user-id="${userId}"]`)
|
||||||
|
const password = passwordInput?.value
|
||||||
|
if (!password) {
|
||||||
|
setMessage('密码不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.updateUserPassword(userId, password)
|
||||||
|
setMessage('密码更新成功')
|
||||||
|
setEditingUserId(null)
|
||||||
|
if (passwordInput) passwordInput.value = ''
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('更新密码失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateUserRole = async (userId, role) => {
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.updateUserRole(userId, role)
|
||||||
|
setMessage('角色更新成功')
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('更新角色失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateUserStatus = async (userId, status) => {
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.updateUserStatus(userId, status)
|
||||||
|
setMessage('状态更新成功')
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('更新状态失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="global-config">加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-config">
|
||||||
|
<div className="global-config-header">
|
||||||
|
<h2>全局配置</h2>
|
||||||
|
<p>管理用户、账号和全局策略配置</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户管理 */}
|
||||||
|
<section className="global-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>用户管理</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => setShowUserForm(!showUserForm)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{showUserForm ? '取消' : '+ 创建用户'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showUserForm && (
|
||||||
|
<div className="form-card">
|
||||||
|
<h4>创建新用户</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||||
|
placeholder="输入用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
|
placeholder="输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select
|
||||||
|
value={newUser.role}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>状态</label>
|
||||||
|
<select
|
||||||
|
value={newUser.status}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn-primary" onClick={handleCreateUser} disabled={busy}>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowUserForm(false)} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={user.role || 'user'}
|
||||||
|
onChange={(e) => handleUpdateUserRole(user.id, e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={user.status || 'active'}
|
||||||
|
onChange={(e) => handleUpdateUserStatus(user.id, e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingUserId === user.id ? (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
data-user-id={user.id}
|
||||||
|
placeholder="新密码"
|
||||||
|
style={{ padding: '4px 8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px' }}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleUpdateUserPassword(user.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => handleUpdateUserPassword(user.id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUserId(null)
|
||||||
|
const input = document.querySelector(`input[data-user-id="${user.id}"]`)
|
||||||
|
if (input) input.value = ''
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingUserId(user.id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 账号管理 */}
|
||||||
|
<section className="global-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>账号管理</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={loadAccounts}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>测试网</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<tr key={account.id}>
|
||||||
|
<td>{account.id}</td>
|
||||||
|
<td>{account.name || '未命名'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-badge ${account.status === 'active' ? 'active' : 'disabled'}`}>
|
||||||
|
{account.status === 'active' ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{account.use_testnet ? '是' : '否'}</td>
|
||||||
|
<td>{account.has_api_key ? '已配置' : '未配置'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalConfig
|
||||||
|
|
@ -630,4 +630,60 @@ export const api = {
|
||||||
}
|
}
|
||||||
return response.json();
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user