feat: 完成券商和用户管理
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { router } from './router';
|
||||
import './App.css';
|
||||
@@ -15,7 +15,9 @@ function App() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
73
apps/web/src/components/ErrorBoundary.tsx
Normal file
73
apps/web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import ErrorPage from './ErrorPage';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// 记录错误信息
|
||||
console.error('ErrorBoundary 捕获到错误:', error, errorInfo);
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// 可以在这里将错误发送到错误监控服务
|
||||
// 例如:Sentry, LogRocket 等
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
130
apps/web/src/components/ErrorPage.css
Normal file
130
apps/web/src/components/ErrorPage.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.error-page {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.error-page-card {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-page-content {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 80px;
|
||||
color: #8b5cf6;
|
||||
margin-bottom: 24px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: #1f2937;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error-details-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.error-detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.error-detail-section h5 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-stack {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.error-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
184
apps/web/src/components/ErrorPage.tsx
Normal file
184
apps/web/src/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Button, Card, Typography, Space, Collapse } from 'antd';
|
||||
import { ReloadOutlined, HomeOutlined, BugOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useRouteError, isRouteErrorResponse } from 'react-router';
|
||||
import type { ErrorInfo } from 'react';
|
||||
import './ErrorPage.css';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface ErrorPageProps {
|
||||
error?: Error | null;
|
||||
errorInfo?: ErrorInfo | null;
|
||||
onReset?: () => void;
|
||||
is404?: boolean;
|
||||
}
|
||||
|
||||
const ErrorPage = ({ error: propError, errorInfo, onReset, is404: propIs404 }: ErrorPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const routeError = useRouteError();
|
||||
|
||||
// 判断是否为 404 错误
|
||||
const is404 =
|
||||
propIs404 ||
|
||||
(isRouteErrorResponse(routeError) && routeError.status === 404) ||
|
||||
(routeError instanceof Error && routeError.message.includes('404'));
|
||||
|
||||
// 获取错误信息
|
||||
const error = propError || (routeError instanceof Error ? routeError : null);
|
||||
const errorMessage = is404
|
||||
? '页面未找到'
|
||||
: isRouteErrorResponse(routeError)
|
||||
? routeError.statusText || '页面加载失败'
|
||||
: error?.message || '未知错误';
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate('/');
|
||||
if (onReset) {
|
||||
onReset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="error-page">
|
||||
<Card className="error-page-card">
|
||||
<div className="error-page-content">
|
||||
<div className="error-icon">
|
||||
{is404 ? <FileSearchOutlined /> : <BugOutlined />}
|
||||
</div>
|
||||
<Title level={2} className="error-title">
|
||||
{is404 ? '页面未找到' : '哎呀,出错了!'}
|
||||
</Title>
|
||||
<Paragraph className="error-description">
|
||||
{is404 ? (
|
||||
<>
|
||||
抱歉,您访问的页面不存在。
|
||||
<br />
|
||||
请检查 URL 是否正确,或返回首页继续浏览。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
应用遇到了一个意外错误,我们已经记录了这个问题。
|
||||
<br />
|
||||
您可以尝试刷新页面或返回首页。
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
|
||||
{errorMessage && !is404 && (
|
||||
<div className="error-message">
|
||||
<Text type="danger" strong>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Space size="middle" className="error-actions">
|
||||
{!is404 && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReload}
|
||||
size="large"
|
||||
>
|
||||
刷新页面
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type={is404 ? 'primary' : 'default'}
|
||||
icon={<HomeOutlined />}
|
||||
onClick={handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
{is404 && (
|
||||
<Button icon={<ReloadOutlined />} onClick={handleGoBack} size="large">
|
||||
返回上页
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{import.meta.env.DEV &&
|
||||
!is404 &&
|
||||
(error ||
|
||||
errorInfo ||
|
||||
(routeError !== null && routeError !== undefined)) && (
|
||||
<Collapse
|
||||
ghost
|
||||
className="error-details"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: '错误详情(开发模式)',
|
||||
children: (
|
||||
<div className="error-details-content">
|
||||
{error && (
|
||||
<div className="error-detail-section">
|
||||
<Title level={5}>错误信息:</Title>
|
||||
<pre className="error-stack">
|
||||
{error.stack || error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isRouteErrorResponse(routeError) && (
|
||||
<div className="error-detail-section">
|
||||
<Title level={5}>路由错误:</Title>
|
||||
<pre className="error-stack">
|
||||
{`状态码: ${routeError.status}\n状态文本: ${routeError.statusText}\n数据: ${JSON.stringify(
|
||||
routeError.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
null,
|
||||
2
|
||||
)}`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo && (
|
||||
<div className="error-detail-section">
|
||||
<Title level={5}>组件堆栈:</Title>
|
||||
<pre className="error-stack">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{routeError !== null &&
|
||||
routeError !== undefined &&
|
||||
!(routeError instanceof Error) &&
|
||||
!isRouteErrorResponse(routeError) && (
|
||||
<div className="error-detail-section">
|
||||
<Title level={5}>路由错误详情:</Title>
|
||||
<pre className="error-stack">
|
||||
{JSON.stringify(
|
||||
routeError as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -5,6 +5,8 @@
|
||||
/* 侧边栏样式 */
|
||||
.main-sider {
|
||||
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -25,26 +27,7 @@
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
/* 侧边栏菜单样式已移至 SidebarMenu.css */
|
||||
|
||||
/* 顶部栏样式 */
|
||||
.main-header {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { Layout, Menu, Avatar, Badge, Drawer, Dropdown, Button, message } from 'antd';
|
||||
import { Layout, Avatar, Badge, Drawer, Dropdown, message } from 'antd';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -13,23 +10,14 @@ import {
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authService } from '@/services/auth';
|
||||
import type { UserInfo } from '@/types/user';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { getPageInfo } from './menuConfig';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
interface MainLayoutProps {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 页面标题映射
|
||||
const pageTitles: Record<string, { title: string; subtitle: string }> = {
|
||||
'/': { title: '资产账户', subtitle: '买股票就是买公司' },
|
||||
'/assets': { title: '资产账户', subtitle: '买股票就是买公司' },
|
||||
'/plans': { title: '交易计划', subtitle: '计划你的交易,交易你的计划' },
|
||||
'/review': { title: '投资复盘', subtitle: '回顾过去是为了更好应对将来' },
|
||||
};
|
||||
|
||||
const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -45,7 +33,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
|
||||
// 根据路由获取页面标题
|
||||
const pageInfo = useMemo(() => {
|
||||
return pageTitles[location.pathname] || pageTitles['/'];
|
||||
return getPageInfo(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 检测移动端
|
||||
@@ -62,33 +50,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/assets',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '资产账户',
|
||||
},
|
||||
{
|
||||
key: '/plans',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '交易计划',
|
||||
},
|
||||
{
|
||||
key: '/review',
|
||||
icon: <EditOutlined />,
|
||||
label: '投资复盘',
|
||||
},
|
||||
];
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
if (isMobile) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
@@ -134,15 +95,6 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
return 'success';
|
||||
};
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '/assets') {
|
||||
return ['/assets'];
|
||||
}
|
||||
return [path];
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
{/* 桌面端侧边栏 */}
|
||||
@@ -165,14 +117,7 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
<div className="logo">{collapsed ? '投' : '投小记'}</div>
|
||||
{!collapsed && <div className="logo-subtitle">VestMind</div>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
<SidebarMenu collapsed={collapsed} user={user} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
@@ -186,12 +131,10 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
bodyStyle={{ padding: 0 }}
|
||||
width={240}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ border: 'none' }}
|
||||
<SidebarMenu
|
||||
collapsed={false}
|
||||
user={user}
|
||||
onMenuClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
@@ -258,7 +201,9 @@ const MainLayout = ({ isAdmin = false }: MainLayoutProps) => {
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
155
apps/web/src/layouts/SidebarMenu.css
Normal file
155
apps/web/src/layouts/SidebarMenu.css
Normal file
@@ -0,0 +1,155 @@
|
||||
.sidebar-menu-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.3s !important;
|
||||
}
|
||||
|
||||
/* 菜单文本样式 - 加大字体并加粗 */
|
||||
.sidebar-menu .ant-menu-item {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* hover状态 - 不缩进,只改变背景 */
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover .ant-menu-title-content {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 选中状态 - 文本缩进 */
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
/* 菜单分组标题 */
|
||||
.sidebar-menu .ant-menu-item-group-title {
|
||||
padding: 12px 20px 8px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar-menu .menu-section-title .ant-menu-item-group-title {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 折叠状态下隐藏分组标题 */
|
||||
.sidebar-menu .ant-menu-item-group-title:empty {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.sidebar-menu .ant-menu-item-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
margin: 8px 20px !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-menu .menu-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
margin: 8px 20px !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 管理员功能区域动画 */
|
||||
.sidebar-menu .admin-section {
|
||||
animation: fadeInDown 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 菜单项图标样式和动画 */
|
||||
.sidebar-menu .ant-menu-item-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 18px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* hover状态 - 图标不缩进,只轻微放大 */
|
||||
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 选中状态下图标也跟随文本缩进 */
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-item-icon {
|
||||
transform: translateX(8px) scale(1.05);
|
||||
}
|
||||
|
||||
/* 折叠状态下的样式调整 */
|
||||
.sidebar-menu .ant-menu-item-group-title {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-menu .ant-menu-item {
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
}
|
||||
99
apps/web/src/layouts/SidebarMenu.tsx
Normal file
99
apps/web/src/layouts/SidebarMenu.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { UserInfo } from '@/types/user';
|
||||
import { getMainMenuItems, getAdminMenuItems } from './menuConfig';
|
||||
import './SidebarMenu.css';
|
||||
|
||||
interface SidebarMenuProps {
|
||||
collapsed: boolean;
|
||||
user: UserInfo | null;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 判断是否为管理员
|
||||
const isAdmin = useMemo(() => {
|
||||
return user?.role === 'admin' || user?.role === 'super_admin';
|
||||
}, [user?.role]);
|
||||
|
||||
// 合并菜单项(带分组)
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
// 获取主要功能菜单项
|
||||
const mainMenuConfigs = getMainMenuItems();
|
||||
const mainMenuItems: MenuProps['items'] = mainMenuConfigs.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
|
||||
// 获取管理员功能菜单项
|
||||
const adminMenuConfigs = getAdminMenuItems();
|
||||
const adminMenuItems: MenuProps['items'] = adminMenuConfigs.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: collapsed ? '' : '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children: mainMenuItems,
|
||||
},
|
||||
];
|
||||
|
||||
// 如果是管理员,添加分隔线和管理员功能
|
||||
if (isAdmin && adminMenuItems.length > 0) {
|
||||
items.push({
|
||||
type: 'divider',
|
||||
className: 'menu-divider',
|
||||
});
|
||||
items.push({
|
||||
type: 'group',
|
||||
label: collapsed ? '' : '管理员功能',
|
||||
className: 'menu-section-title admin-section',
|
||||
children: adminMenuItems,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [collapsed, isAdmin]);
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
if (onMenuClick) {
|
||||
onMenuClick();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '/assets') {
|
||||
return ['/assets'];
|
||||
}
|
||||
return [path];
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
155
apps/web/src/layouts/menuConfig.tsx
Normal file
155
apps/web/src/layouts/menuConfig.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 路由菜单配置项
|
||||
*/
|
||||
export interface RouteMenuConfig {
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 菜单键值(通常与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标 */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面副标题 */
|
||||
subtitle: string;
|
||||
/** 菜单分组:'main' 主要功能,'admin' 管理员功能 */
|
||||
group: 'main' | 'admin';
|
||||
/** 是否需要管理员权限 */
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由菜单配置列表
|
||||
*/
|
||||
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
{
|
||||
path: '/assets',
|
||||
key: '/assets',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '资产账户',
|
||||
title: '资产账户',
|
||||
subtitle: '买股票就是买公司',
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/plans',
|
||||
key: '/plans',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '交易计划',
|
||||
title: '交易计划',
|
||||
subtitle: '计划你的交易,交易你的计划',
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/review',
|
||||
key: '/review',
|
||||
icon: <EditOutlined />,
|
||||
label: '投资复盘',
|
||||
title: '投资复盘',
|
||||
subtitle: '回顾过去是为了更好应对将来',
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
key: '/user',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
title: '用户管理',
|
||||
subtitle: '管理用户信息',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/broker',
|
||||
key: '/broker',
|
||||
icon: <BankOutlined />,
|
||||
label: '券商管理',
|
||||
title: '券商管理',
|
||||
subtitle: '管理券商信息',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/seo',
|
||||
key: '/seo',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'SEO配置',
|
||||
title: 'SEO配置',
|
||||
subtitle: '优化搜索引擎可见性',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
key: '/analytics',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '数据统计',
|
||||
title: '数据统计',
|
||||
subtitle: '了解用户行为与系统数据',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据路径获取页面标题信息
|
||||
*/
|
||||
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
|
||||
// 处理根路径
|
||||
if (path === '/' || path === '') {
|
||||
const defaultRoute = routeMenuConfig.find((item) => item.path === '/assets');
|
||||
return defaultRoute
|
||||
? { title: defaultRoute.title, subtitle: defaultRoute.subtitle }
|
||||
: { title: '资产账户', subtitle: '买股票就是买公司' };
|
||||
}
|
||||
|
||||
const config = routeMenuConfig.find((item) => item.path === path);
|
||||
return config
|
||||
? { title: config.title, subtitle: config.subtitle }
|
||||
: { title: '资产账户', subtitle: '买股票就是买公司' };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主要功能菜单项
|
||||
*/
|
||||
export const getMainMenuItems = () => {
|
||||
return routeMenuConfig.filter((item) => item.group === 'main');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取管理员功能菜单项
|
||||
*/
|
||||
export const getAdminMenuItems = () => {
|
||||
return routeMenuConfig.filter((item) => item.group === 'admin' && item.requireAdmin);
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面标题映射(用于向后兼容)
|
||||
*/
|
||||
export const pageTitles: Record<string, { title: string; subtitle: string }> = (() => {
|
||||
const titles: Record<string, { title: string; subtitle: string }> = {
|
||||
'/': getPageInfo('/assets'),
|
||||
};
|
||||
|
||||
routeMenuConfig.forEach((config) => {
|
||||
titles[config.path] = {
|
||||
title: config.title,
|
||||
subtitle: config.subtitle,
|
||||
};
|
||||
});
|
||||
|
||||
return titles;
|
||||
})();
|
||||
172
apps/web/src/pages/broker/BrokerFormModal.tsx
Normal file
172
apps/web/src/pages/broker/BrokerFormModal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Modal, Form, Input, Select, Upload, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { brokerService } from '@/services/broker';
|
||||
import type { Broker, CreateBrokerRequest } from '@/types/broker';
|
||||
import { REGION_OPTIONS } from '@/types/broker';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface BrokerFormModalProps {
|
||||
visible: boolean;
|
||||
editingBroker: Broker | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!editingBroker;
|
||||
|
||||
// 当编辑数据变化时,更新表单
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (editingBroker) {
|
||||
form.setFieldsValue({
|
||||
brokerCode: editingBroker.brokerCode,
|
||||
brokerName: editingBroker.brokerName,
|
||||
region: editingBroker.region,
|
||||
brokerImage: editingBroker.brokerImage,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}, [visible, editingBroker, form]);
|
||||
|
||||
// 图片上传配置(仅UI,不上传)
|
||||
const uploadProps: UploadProps = {
|
||||
name: 'file',
|
||||
listType: 'picture-card',
|
||||
maxCount: 1,
|
||||
beforeUpload: () => {
|
||||
// 阻止自动上传
|
||||
return false;
|
||||
},
|
||||
onChange: (info) => {
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 文件上传成功`);
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(`${info.file.name} 文件上传失败`);
|
||||
}
|
||||
},
|
||||
onRemove: () => {
|
||||
form.setFieldValue('brokerImage', '');
|
||||
},
|
||||
};
|
||||
|
||||
// 处理图片变化(仅UI,实际应该上传后获取URL)
|
||||
const handleImageChange = (info: any) => {
|
||||
// 这里只做UI处理,实际应该调用上传接口获取图片URL
|
||||
// 临时处理:使用本地预览
|
||||
if (info.file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// 实际应该调用上传接口,这里只是示例
|
||||
// form.setFieldValue('brokerImage', uploadUrl);
|
||||
};
|
||||
reader.readAsDataURL(info.file);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const formData: CreateBrokerRequest = {
|
||||
brokerCode: values.brokerCode,
|
||||
brokerName: values.brokerName,
|
||||
region: values.region,
|
||||
brokerImage: values.brokerImage || undefined,
|
||||
};
|
||||
|
||||
if (isEdit && editingBroker) {
|
||||
await brokerService.updateBroker(editingBroker.brokerId, formData);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await brokerService.createBroker(formData);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证错误
|
||||
return;
|
||||
}
|
||||
message.error(error.message || (isEdit ? '更新失败' : '创建失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? '编辑券商' : '新建券商'}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" autoComplete="off" style={{ marginTop: 20 }}>
|
||||
<Form.Item
|
||||
name="brokerCode"
|
||||
label="券商代码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入券商代码' },
|
||||
{ max: 50, message: '券商代码不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入券商代码" disabled={isEdit} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="brokerName"
|
||||
label="券商名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入券商名称' },
|
||||
{ max: 100, message: '券商名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入券商名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="region"
|
||||
label="地区"
|
||||
rules={[{ required: true, message: '请选择地区' }]}
|
||||
>
|
||||
<Select placeholder="请选择地区">
|
||||
{REGION_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="brokerImage"
|
||||
label="券商Logo"
|
||||
rules={[{ max: 200, message: '图片地址不能超过200个字符' }]}
|
||||
>
|
||||
<Input placeholder="请输入图片URL(或使用下方上传组件)" allowClear />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传Logo(仅UI演示)">
|
||||
<Upload {...uploadProps} onChange={handleImageChange} accept="image/*">
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||
注意:上传功能仅做UI演示,实际需要调用上传接口获取图片URL
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrokerFormModal;
|
||||
38
apps/web/src/pages/broker/BrokerPage.css
Normal file
38
apps/web/src/pages/broker/BrokerPage.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.broker-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.broker-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.broker-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.broker-action-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.broker-page .ant-table {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.broker-page .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.broker-search-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.broker-action-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
328
apps/web/src/pages/broker/BrokerPage.tsx
Normal file
328
apps/web/src/pages/broker/BrokerPage.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Image,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
App as AntdApp,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { brokerService } from '@/services/broker';
|
||||
import type { Broker, QueryBrokerRequest } from '@/types/broker';
|
||||
import { REGION_OPTIONS, getRegionText } from '@/types/broker';
|
||||
import BrokerFormModal from './BrokerFormModal';
|
||||
import './BrokerPage.css';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const BrokerPage = () => {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [brokers, setBrokers] = useState<Broker[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingBroker, setEditingBroker] = useState<Broker | null>(null);
|
||||
const formRef = useRef<QueryBrokerRequest>({});
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params?: QueryBrokerRequest, resetPage = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = resetPage ? 1 : pagination.current;
|
||||
const pageSize = pagination.pageSize;
|
||||
|
||||
const queryParams: QueryBrokerRequest = {
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
...formRef.current,
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await brokerService.getBrokerList(queryParams);
|
||||
|
||||
setBrokers(response.list);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: response.pagination.current_page,
|
||||
pageSize: response.pagination.page_size,
|
||||
total: response.pagination.total,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '加载券商列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData({}, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 当分页改变时,重新加载数据
|
||||
useEffect(() => {
|
||||
// 避免初始加载时重复请求
|
||||
if (pagination.current > 0 && pagination.pageSize > 0) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
// 查询
|
||||
const handleSearch = () => {
|
||||
const values = form.getFieldsValue();
|
||||
formRef.current = {
|
||||
brokerCode: values.brokerCode || undefined,
|
||||
brokerName: values.brokerName || undefined,
|
||||
region: values.region || undefined,
|
||||
};
|
||||
loadData(formRef.current, true);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
formRef.current = {};
|
||||
loadData({}, true);
|
||||
};
|
||||
|
||||
// 新建
|
||||
const handleCreate = () => {
|
||||
setEditingBroker(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (record: Broker) => {
|
||||
setEditingBroker(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await brokerService.deleteBroker(id);
|
||||
messageApi.success('删除成功');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存成功回调
|
||||
const handleSaveSuccess = () => {
|
||||
setModalVisible(false);
|
||||
setEditingBroker(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<Broker> = [
|
||||
{
|
||||
title: '券商Logo',
|
||||
dataIndex: 'brokerImage',
|
||||
key: 'brokerImage',
|
||||
width: 100,
|
||||
render: (image: string) => {
|
||||
if (image) {
|
||||
return (
|
||||
<Image
|
||||
src={image}
|
||||
alt="券商Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ objectFit: 'contain' }}
|
||||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50'%3E%3Crect width='50' height='50' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELogo%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
Logo
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '券商代码',
|
||||
dataIndex: 'brokerCode',
|
||||
key: 'brokerCode',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '券商名称',
|
||||
dataIndex: 'brokerName',
|
||||
key: 'brokerName',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '地区',
|
||||
dataIndex: 'region',
|
||||
key: 'region',
|
||||
width: 120,
|
||||
render: (region: string) => getRegionText(region),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: Broker) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||
修改
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个券商吗?"
|
||||
onConfirm={() => handleDelete(record.brokerId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="broker-page">
|
||||
<Card>
|
||||
{/* 查询表单 */}
|
||||
<Form form={form} layout="inline" className="broker-search-form">
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="brokerCode" label="券商代码">
|
||||
<Input
|
||||
placeholder="请输入券商代码"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="brokerName" label="券商名称">
|
||||
<Input
|
||||
placeholder="请输入券商名称"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="region" label="地区">
|
||||
<Select
|
||||
placeholder="请选择地区"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{REGION_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="broker-action-bar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新建券商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={brokers}
|
||||
rowKey="brokerId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<BrokerFormModal
|
||||
visible={modalVisible}
|
||||
editingBroker={editingBroker}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
setEditingBroker(null);
|
||||
}}
|
||||
onSuccess={handleSaveSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrokerPage;
|
||||
1
apps/web/src/pages/broker/index.ts
Normal file
1
apps/web/src/pages/broker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './BrokerPage';
|
||||
137
apps/web/src/pages/user/UserDetailModal.tsx
Normal file
137
apps/web/src/pages/user/UserDetailModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Modal, Descriptions, Avatar, Tag, Image, Space, Button, Popconfirm } from 'antd';
|
||||
import { UserOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { User } from '@/types/user';
|
||||
import { getRoleText, getStatusText } from '@/types/user';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface UserDetailModalProps {
|
||||
visible: boolean;
|
||||
user: User | null;
|
||||
onCancel: () => void;
|
||||
onDelete?: (userId: number) => void;
|
||||
}
|
||||
|
||||
const UserDetailModal = ({ visible, user, onCancel, onDelete }: UserDetailModalProps) => {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFrozen = user.status === 'inactive';
|
||||
const isSuperAdmin = user.role === 'super_admin';
|
||||
const canDelete = isFrozen && !isSuperAdmin && onDelete;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="用户详情"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="close" onClick={onCancel}>
|
||||
关闭
|
||||
</Button>,
|
||||
canDelete && (
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定要删除这个用户吗?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => onDelete?.(user.userId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button key="delete" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
width={700}
|
||||
>
|
||||
{/* 头像和昵称区域 */}
|
||||
<div style={{ marginBottom: 24, textAlign: 'center' }}>
|
||||
<Space size={16} align="center">
|
||||
{user.avatarUrl ? (
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt="头像"
|
||||
width={60}
|
||||
height={60}
|
||||
style={{ borderRadius: 4 }}
|
||||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' fill='%23f0f0f0'/%3E%3Cpath d='M30 20c5.5 0 10 4.5 10 10s-4.5 10-10 10-10-4.5-10-10 4.5-10 10-10zm0 24c6.6 0 20 3.3 20 10v6H10v-6c0-6.7 13.4-10 20-10z' fill='%23999'/%3E%3C/svg%3E"
|
||||
/>
|
||||
) : (
|
||||
<Avatar size={60} icon={<UserOutlined />} />
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
}}
|
||||
>
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Descriptions column={2} bordered>
|
||||
<Descriptions.Item label="用户ID">{user.userId}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="邮箱">{user.email}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="电话">{user.phone || '-'}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="角色">
|
||||
<Tag
|
||||
color={
|
||||
user.role === 'super_admin'
|
||||
? 'red'
|
||||
: user.role === 'admin'
|
||||
? 'orange'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{getRoleText(user.role)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag
|
||||
color={
|
||||
user.status === 'active'
|
||||
? 'success'
|
||||
: user.status === 'inactive'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
>
|
||||
{getStatusText(user.status)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
|
||||
{user.openId && (
|
||||
<Descriptions.Item label="微信OpenID">{user.openId}</Descriptions.Item>
|
||||
)}
|
||||
|
||||
{user.unionId && (
|
||||
<Descriptions.Item label="微信UnionID">{user.unionId}</Descriptions.Item>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label="创建时间" span={2}>
|
||||
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="更新时间" span={2}>
|
||||
{dayjs(user.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="最后登录时间" span={2}>
|
||||
{user.lastLoginAt ? dayjs(user.lastLoginAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetailModal;
|
||||
7
apps/web/src/pages/user/UserPage.css
Normal file
7
apps/web/src/pages/user/UserPage.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.user-page .user-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-page .user-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
431
apps/web/src/pages/user/UserPage.tsx
Normal file
431
apps/web/src/pages/user/UserPage.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Avatar,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
App as AntdApp,
|
||||
Tag,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { userService } from '@/services/user';
|
||||
import type { User, QueryUserRequest } from '@/types/user';
|
||||
import { USER_ROLE_OPTIONS, getRoleText, getStatusText, USER_STATUS_OPTIONS } from '@/types/user';
|
||||
import UserDetailModal from './UserDetailModal';
|
||||
import dayjs from 'dayjs';
|
||||
import './UserPage.css';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const UserPage = () => {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const formRef = useRef<QueryUserRequest>({});
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params?: QueryUserRequest, resetPage = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = resetPage ? 1 : pagination.current;
|
||||
const pageSize = pagination.pageSize;
|
||||
|
||||
const queryParams: QueryUserRequest = {
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
...formRef.current,
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await userService.getUserList(queryParams);
|
||||
|
||||
setUsers(response.list);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: response.pagination.current_page,
|
||||
pageSize: response.pagination.page_size,
|
||||
total: response.pagination.total,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '加载用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData({}, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 当分页改变时,重新加载数据
|
||||
useEffect(() => {
|
||||
// 避免初始加载时重复请求
|
||||
if (pagination.current > 0 && pagination.pageSize > 0) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
// 查询
|
||||
const handleSearch = () => {
|
||||
const values = form.getFieldsValue();
|
||||
formRef.current = {
|
||||
username: values.username || undefined,
|
||||
nickname: values.nickname || undefined,
|
||||
email: values.email || undefined,
|
||||
phone: values.phone || undefined,
|
||||
role: values.role || undefined,
|
||||
status: values.status || undefined,
|
||||
};
|
||||
loadData(formRef.current, true);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
formRef.current = {};
|
||||
loadData({}, true);
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleView = (record: User) => {
|
||||
setSelectedUser(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
// 冻结/解冻
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
try {
|
||||
const newStatus = user.status === 'active' ? 'inactive' : 'active';
|
||||
await userService.updateUserStatus(user.userId, newStatus);
|
||||
messageApi.success(newStatus === 'inactive' ? '用户已冻结' : '用户已解冻');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await userService.deleteUser(id);
|
||||
messageApi.success('删除成功');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<User> = [
|
||||
{
|
||||
title: '用户头像',
|
||||
dataIndex: 'avatarUrl',
|
||||
key: 'avatarUrl',
|
||||
width: 80,
|
||||
render: (avatarUrl: string) => {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="头像"
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='%23f0f0f0'/%3E%3Cpath d='M10 7c1.7 0 3 1.3 3 3s-1.3 3-3 3-3-1.3-3-3 1.3-3 3-3zm0 8c2.2 0 6.7 1.1 6.7 3.3v1.7H3.3v-1.7c0-2.2 4.5-3.3 6.7-3.3z' fill='%23999'/%3E%3C/svg%3E"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Avatar size={32} icon={<UserOutlined />} style={{ fontSize: 12 }} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
width: 120,
|
||||
render: (nickname: string) => nickname || '-',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120,
|
||||
render: (phone: string) => phone || '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '最后登录时间',
|
||||
dataIndex: 'lastLoginAt',
|
||||
key: 'lastLoginAt',
|
||||
width: 160,
|
||||
render: (date: Date | undefined) =>
|
||||
date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string, _record: User) => (
|
||||
<Tag
|
||||
color={
|
||||
status === 'active'
|
||||
? 'success'
|
||||
: status === 'inactive'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
>
|
||||
{getStatusText(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (role: string) => (
|
||||
<Tag color={role === 'super_admin' ? 'red' : role === 'admin' ? 'orange' : 'blue'}>
|
||||
{getRoleText(role)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: User) => {
|
||||
const isFrozen = record.status === 'inactive';
|
||||
const isSuperAdmin = record.role === 'super_admin';
|
||||
return (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{/* 超级管理员不允许冻结 */}
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
{isFrozen ? (
|
||||
<Popconfirm
|
||||
title="确定要解冻这个用户吗?"
|
||||
description="解冻后用户可以正常使用"
|
||||
onConfirm={() => handleToggleStatus(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" icon={<UnlockOutlined />}>
|
||||
解冻
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title="确定要冻结这个用户吗?"
|
||||
description="冻结后用户将无法登录和使用系统"
|
||||
onConfirm={() => handleToggleStatus(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" icon={<LockOutlined />}>
|
||||
冻结
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-page">
|
||||
<Card>
|
||||
{/* 查询表单 */}
|
||||
<Form form={form} layout="inline" className="user-search-form">
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="nickname" label="昵称">
|
||||
<Input
|
||||
placeholder="请输入昵称"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input
|
||||
placeholder="请输入邮箱"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input
|
||||
placeholder="请输入电话"
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="role" label="角色" initialValue="">
|
||||
<Select
|
||||
placeholder="请选择角色"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{USER_ROLE_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="status" label="状态" initialValue="">
|
||||
<Select
|
||||
placeholder="请选择状态"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{USER_STATUS_OPTIONS.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 用户详情弹窗 */}
|
||||
<UserDetailModal
|
||||
visible={detailModalVisible}
|
||||
user={selectedUser}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
onDelete={async (userId: number) => {
|
||||
try {
|
||||
await handleDelete(userId);
|
||||
setDetailModalVisible(false);
|
||||
setSelectedUser(null);
|
||||
} catch (error) {
|
||||
// 错误已在 handleDelete 中处理
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
1
apps/web/src/pages/user/index.ts
Normal file
1
apps/web/src/pages/user/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './UserPage';
|
||||
@@ -1,15 +1,20 @@
|
||||
import { lazy } from 'react';
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
import MainLayout from '../layouts/MainLayout';
|
||||
import ProtectedRoute from '../components/ProtectedRoute';
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
import AssetsPage from '../pages/AssetsPage';
|
||||
import PlansPage from '../pages/PlansPage';
|
||||
import ReviewPage from '../pages/ReviewPage';
|
||||
const AssetsPage = lazy(() => import('../pages/AssetsPage'));
|
||||
const PlansPage = lazy(() => import('../pages/PlansPage'));
|
||||
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
|
||||
const BrokerPage = lazy(() => import('../pages/broker'));
|
||||
const UserPage = lazy(() => import('@/pages/user'));
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -18,6 +23,7 @@ export const router = createBrowserRouter([
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
@@ -35,6 +41,18 @@ export const router = createBrowserRouter([
|
||||
path: 'review',
|
||||
element: <ReviewPage />,
|
||||
},
|
||||
{
|
||||
path: 'broker',
|
||||
element: <BrokerPage />,
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
element: <UserPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <ErrorPage is404={true} />,
|
||||
},
|
||||
]);
|
||||
|
||||
59
apps/web/src/services/broker.ts
Normal file
59
apps/web/src/services/broker.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
Broker,
|
||||
CreateBrokerRequest,
|
||||
UpdateBrokerRequest,
|
||||
QueryBrokerRequest,
|
||||
PaginatedBrokerResponse,
|
||||
ApiResponse,
|
||||
} from '@/types/broker';
|
||||
|
||||
/**
|
||||
* 券商服务
|
||||
*/
|
||||
class BrokerService {
|
||||
/**
|
||||
* 查询券商列表(分页)
|
||||
*/
|
||||
async getBrokerList(params: QueryBrokerRequest): Promise<PaginatedBrokerResponse> {
|
||||
// api.get 返回的是 TransformInterceptor 处理后的 { code, message, data }
|
||||
// 其中 data 就是 PaginatedBrokerResponse
|
||||
const response = await api.get<ApiResponse<PaginatedBrokerResponse>>('/broker', { params });
|
||||
// 如果 response 已经是 PaginatedBrokerResponse 格式,直接返回
|
||||
if ('list' in response && 'pagination' in response) {
|
||||
return response as PaginatedBrokerResponse;
|
||||
}
|
||||
// 否则从 ApiResponse 中取 data
|
||||
return (response as ApiResponse<PaginatedBrokerResponse>).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询券商
|
||||
*/
|
||||
async getBrokerById(id: number): Promise<Broker> {
|
||||
return await api.get<Broker>(`/broker/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建券商
|
||||
*/
|
||||
async createBroker(data: CreateBrokerRequest): Promise<Broker> {
|
||||
return await api.post<Broker>('/broker', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新券商
|
||||
*/
|
||||
async updateBroker(id: number, data: UpdateBrokerRequest): Promise<Broker> {
|
||||
return await api.patch<Broker>(`/broker/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除券商
|
||||
*/
|
||||
async deleteBroker(id: number): Promise<void> {
|
||||
await api.delete(`/broker/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const brokerService = new BrokerService();
|
||||
46
apps/web/src/services/user.ts
Normal file
46
apps/web/src/services/user.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { api } from './api';
|
||||
import type { User, QueryUserRequest, PaginatedUserResponse } from '@/types/user';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
/**
|
||||
* 用户服务
|
||||
*/
|
||||
class UserService {
|
||||
/**
|
||||
* 查询用户列表(分页)
|
||||
*/
|
||||
async getUserList(params: QueryUserRequest): Promise<PaginatedUserResponse> {
|
||||
const response = await api.get<ApiResponse<PaginatedUserResponse>>('/user', {
|
||||
params,
|
||||
});
|
||||
// 如果 response 已经是 PaginatedUserResponse 格式,直接返回
|
||||
if ('list' in response && 'pagination' in response) {
|
||||
return response as PaginatedUserResponse;
|
||||
}
|
||||
// 否则从 ApiResponse 中取 data
|
||||
return (response as ApiResponse<PaginatedUserResponse>).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询用户
|
||||
*/
|
||||
async getUserById(id: number): Promise<User> {
|
||||
return await api.get<User>(`/user/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态(冻结/解冻)
|
||||
*/
|
||||
async updateUserStatus(id: number, status: 'active' | 'inactive'): Promise<User> {
|
||||
return await api.patch<User>(`/user/${id}`, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
await api.delete(`/user/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
90
apps/web/src/types/broker.ts
Normal file
90
apps/web/src/types/broker.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 券商信息接口
|
||||
*/
|
||||
export interface Broker {
|
||||
brokerId: number;
|
||||
brokerCode: string;
|
||||
brokerName: string;
|
||||
brokerImage?: string;
|
||||
region: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建券商请求参数
|
||||
*/
|
||||
export interface CreateBrokerRequest {
|
||||
brokerCode: string;
|
||||
brokerName: string;
|
||||
brokerImage?: string;
|
||||
region: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新券商请求参数
|
||||
*/
|
||||
export interface UpdateBrokerRequest extends Partial<CreateBrokerRequest> {}
|
||||
|
||||
/**
|
||||
* 查询券商请求参数
|
||||
*/
|
||||
export interface QueryBrokerRequest {
|
||||
brokerId?: number;
|
||||
brokerCode?: string;
|
||||
brokerName?: string;
|
||||
region?: string;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页信息
|
||||
*/
|
||||
export interface PaginationInfo {
|
||||
total: number;
|
||||
total_page: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PaginatedBrokerResponse {
|
||||
list: Broker[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 响应格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地区选项
|
||||
*/
|
||||
export const REGION_OPTIONS = [
|
||||
{ label: '中国大陆', value: 'CN' },
|
||||
{ label: '香港', value: 'HK' },
|
||||
{ label: '美国', value: 'US' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 获取地区显示文本
|
||||
*/
|
||||
export const getRegionText = (region: string): string => {
|
||||
const option = REGION_OPTIONS.find((opt) => opt.value === region);
|
||||
return option ? option.label : region;
|
||||
};
|
||||
@@ -17,6 +17,11 @@ export interface UserInfo {
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户类型(用于列表展示,与UserInfo相同)
|
||||
*/
|
||||
export type User = UserInfo;
|
||||
|
||||
/**
|
||||
* 登录请求参数
|
||||
*/
|
||||
@@ -32,3 +37,67 @@ export interface LoginResponse {
|
||||
accessToken: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户请求参数
|
||||
*/
|
||||
export interface QueryUserRequest {
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PaginatedUserResponse {
|
||||
list: User[];
|
||||
pagination: {
|
||||
total: number;
|
||||
total_page: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户角色选项
|
||||
*/
|
||||
export const USER_ROLE_OPTIONS = [
|
||||
{ label: '不限', value: '' },
|
||||
{ label: '普通用户', value: 'user' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '超级管理员', value: 'super_admin' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 用户状态选项
|
||||
*/
|
||||
export const USER_STATUS_OPTIONS = [
|
||||
{ label: '不限', value: '' },
|
||||
{ label: '活跃', value: 'active' },
|
||||
{ label: '冻结', value: 'inactive' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 获取角色显示文本
|
||||
*/
|
||||
export const getRoleText = (role: string): string => {
|
||||
const option = USER_ROLE_OPTIONS.find((opt) => opt.value === role);
|
||||
return option ? option.label : role;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态显示文本
|
||||
*/
|
||||
export const getStatusText = (status: string): string => {
|
||||
const option = USER_STATUS_OPTIONS.find((opt) => opt.value === status);
|
||||
return option ? option.label : status;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user