feat: 完成券商和用户管理

This commit is contained in:
R524809
2026-01-07 16:21:16 +08:00
parent 712f66b725
commit 457ba6d765
33 changed files with 2851 additions and 177 deletions

View File

@@ -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>
);
}

View 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;

View 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%;
}
}

View 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;

View File

@@ -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 {

View File

@@ -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>

View 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;
}
}

View 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;

View 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;
})();

View 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;

View 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;
}
}

View 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;

View File

@@ -0,0 +1 @@
export { default } from './BrokerPage';

View 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;

View File

@@ -0,0 +1,7 @@
.user-page .user-search-form {
margin-bottom: 16px;
}
.user-page .user-search-form .ant-form-item {
margin-bottom: 16px;
}

View 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;

View File

@@ -0,0 +1 @@
export { default } from './UserPage';

View File

@@ -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} />,
},
]);

View 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();

View 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();

View 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;
};

View File

@@ -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;
};