feat:生文
This commit is contained in:
@@ -10,7 +10,7 @@ function App() {
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#93D333',
|
||||
colorPrimary: '#4E9B6E',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.error-icon {
|
||||
font-size: 80px;
|
||||
color: #93D333;
|
||||
color: #4E9B6E;
|
||||
margin-bottom: 24px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const WordCard: React.FC<WordCardProps> = ({ word, onClick }) => {
|
||||
<div className="word-card-pinyin">{pinyinDisplay}</div>
|
||||
<div className="word-card-svg-indicator">
|
||||
{hasSvg ? (
|
||||
<CheckCircleOutlined style={{ color: '#93D333' }} />
|
||||
<CheckCircleOutlined style={{ color: '#4E9B6E' }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.main-sider {
|
||||
background: linear-gradient(180deg, #93D333 0%, #7CB518 100%) !important;
|
||||
background: linear-gradient(180deg, #4E9B6E 0%, #3D8260 100%) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
IdcardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authService } from '@/services/auth';
|
||||
@@ -43,6 +44,12 @@ const MainLayout = () => {
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <IdcardOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
@@ -120,8 +127,8 @@ const MainLayout = () => {
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className="user-info" style={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
style={{
|
||||
backgroundColor: '#93D333',
|
||||
style={{
|
||||
backgroundColor: '#4E9B6E',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
src={user?.avatarUrl}
|
||||
|
||||
@@ -79,6 +79,8 @@ const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => {
|
||||
if (path === '/' || path === '/words') {
|
||||
return ['/words'];
|
||||
}
|
||||
if (path === '/users') return ['/users'];
|
||||
if (path === '/profile') return ['/profile'];
|
||||
return [path];
|
||||
}, [location.pathname]);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
FileTextOutlined,
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
IdcardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -41,25 +43,35 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
group: 'main',
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
title: '系统设置',
|
||||
subtitle: '系统配置管理',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
key: '/analytics',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '数据统计',
|
||||
title: '数据统计',
|
||||
subtitle: '了解系统数据',
|
||||
path: '/users',
|
||||
key: '/users',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
title: '用户管理',
|
||||
subtitle: '管理后台用户',
|
||||
group: 'admin',
|
||||
requireAdmin: true,
|
||||
},
|
||||
// {
|
||||
// path: '/settings',
|
||||
// key: '/settings',
|
||||
// icon: <SettingOutlined />,
|
||||
// label: '系统设置',
|
||||
// title: '系统设置',
|
||||
// subtitle: '系统配置管理',
|
||||
// group: 'admin',
|
||||
// requireAdmin: true,
|
||||
// },
|
||||
// {
|
||||
// path: '/analytics',
|
||||
// key: '/analytics',
|
||||
// icon: <DashboardOutlined />,
|
||||
// label: '数据统计',
|
||||
// title: '数据统计',
|
||||
// subtitle: '了解系统数据',
|
||||
// group: 'admin',
|
||||
// requireAdmin: true,
|
||||
// },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authService } from '@/services/auth';
|
||||
import type { LoginRequest } from '@/services/auth';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleLogin = async (values: LoginRequest) => {
|
||||
try {
|
||||
// TODO: 实现实际的登录逻辑
|
||||
// await authService.login(values);
|
||||
// navigate('/words');
|
||||
message.info('登录功能待实现');
|
||||
} catch (error) {
|
||||
message.error('登录失败,请检查用户名和密码');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<Card className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>涂鸦丫管理后台</h1>
|
||||
<p>请登录您的账户</p>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={handleLogin}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -7,106 +7,108 @@ import { wordsService } from '@/services/words';
|
||||
import type { Word } from '@/types/words';
|
||||
import './index.css';
|
||||
|
||||
export const WordsPage: React.FC = () => {
|
||||
const [words, setWords] = useState<Word[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedWord, setSelectedWord] = useState<Word | null>(null);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [limit] = useState(9);
|
||||
const WordsPage: React.FC = () => {
|
||||
const [words, setWords] = useState<Word[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedWord, setSelectedWord] = useState<Word | null>(null);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [limit] = useState(9);
|
||||
|
||||
useEffect(() => {
|
||||
loadWords();
|
||||
}, [page, search]);
|
||||
useEffect(() => {
|
||||
loadWords();
|
||||
}, [page, search]);
|
||||
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await wordsService.getWords({
|
||||
search: search || undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setWords(response.data);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load words:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await wordsService.getWords({
|
||||
search: search || undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setWords(response.data);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load words:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = async (word: Word) => {
|
||||
try {
|
||||
// 加载完整数据
|
||||
const fullWord = await wordsService.getWord(word.id);
|
||||
setSelectedWord(fullWord);
|
||||
setDrawerVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load word details:', error);
|
||||
}
|
||||
};
|
||||
const handleCardClick = async (word: Word) => {
|
||||
try {
|
||||
// 加载完整数据
|
||||
const fullWord = await wordsService.getWord(word.id);
|
||||
setSelectedWord(fullWord);
|
||||
setDrawerVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load word details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
};
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="words-page">
|
||||
<div className="words-page-header">
|
||||
<h1>汉字管理</h1>
|
||||
<Input
|
||||
placeholder="搜索汉字"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{words.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<>
|
||||
<div className="words-grid">
|
||||
{words.map((word) => (
|
||||
<WordCard
|
||||
key={word.id}
|
||||
word={word}
|
||||
onClick={() => handleCardClick(word)}
|
||||
return (
|
||||
<div className="words-page">
|
||||
<div className="words-page-header">
|
||||
<h1>汉字管理</h1>
|
||||
<Input
|
||||
placeholder="搜索汉字"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="words-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={limit}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `共 ${total} 个汉字`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<WordDetailDrawer
|
||||
word={selectedWord}
|
||||
visible={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
onRefresh={loadWords}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<Spin spinning={loading}>
|
||||
{words.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<>
|
||||
<div className="words-grid">
|
||||
{words.map((word) => (
|
||||
<WordCard
|
||||
key={word.id}
|
||||
word={word}
|
||||
onClick={() => handleCardClick(word)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="words-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={limit}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `共 ${total} 个汉字`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<WordDetailDrawer
|
||||
word={selectedWord}
|
||||
visible={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
onRefresh={loadWords}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordsPage;
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #93D333 0%, #7CB518 100%);
|
||||
background: linear-gradient(135deg, #E6F3EC 0%, #D1E8DB 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background: white;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -30,3 +32,19 @@
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form .ant-input-affix-wrapper,
|
||||
.login-form .ant-input {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Form, Input, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authService } from '@/services/auth';
|
||||
import type { LoginRequest } from '@/services/auth';
|
||||
import './LoginPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authService.isAuthenticated()) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const onFinish = async (values: LoginRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.login(values);
|
||||
message.success('登录成功');
|
||||
navigate('/', { replace: true });
|
||||
} catch (error: any) {
|
||||
const msg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'登录失败,请检查用户名/邮箱和密码';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>涂鸦丫管理后台</h1>
|
||||
<p>请登录您的账户</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="login-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="usernameOrEmail"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名或邮箱' },
|
||||
{ min: 3, message: '用户名或邮箱至少 3 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名或邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少 6 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="login-button"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './LoginPage';
|
||||
@@ -0,0 +1,15 @@
|
||||
.profile-content {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.profile-avatar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-field-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Tabs,
|
||||
Card,
|
||||
Avatar,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Space,
|
||||
App as AntdApp,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { authService } from '@/services/auth';
|
||||
import { usersService } from '@/services/users';
|
||||
import type { User } from '@/types/user';
|
||||
import dayjs from 'dayjs';
|
||||
import './ProfilePage.css';
|
||||
|
||||
function calculateUsageDays(createdAt: string | Date): string {
|
||||
const created = dayjs(createdAt);
|
||||
const now = dayjs();
|
||||
const days = now.diff(created, 'day');
|
||||
if (days < 365) return `${days}天`;
|
||||
const years = Math.floor(days / 365);
|
||||
const remaining = days % 365;
|
||||
return `${years}年${remaining}天`;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { message: messageApi } = AntdApp.useApp();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [infoForm] = Form.useForm();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const loadUser = async () => {
|
||||
if (loadingRef.current) return;
|
||||
const current = authService.getUser();
|
||||
if (!current) {
|
||||
messageApi.error('未找到用户信息');
|
||||
return;
|
||||
}
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await usersService.getById(current.id);
|
||||
setUser(data);
|
||||
infoForm.setFieldsValue({
|
||||
realName: data.realName ?? '',
|
||||
email: data.email ?? '',
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message ?? '加载用户信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (user) {
|
||||
infoForm.setFieldsValue({
|
||||
realName: user.realName ?? '',
|
||||
email: user.email ?? '',
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
infoForm.resetFields();
|
||||
};
|
||||
|
||||
const handleUpdateInfo = async () => {
|
||||
try {
|
||||
await infoForm.validateFields();
|
||||
if (!user) return;
|
||||
Modal.confirm({
|
||||
title: '确认更新',
|
||||
content: '确定要更新个人信息吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const v = infoForm.getFieldsValue();
|
||||
await usersService.updateUser(user.id, {
|
||||
realName: v.realName || undefined,
|
||||
email: v.email || undefined,
|
||||
});
|
||||
messageApi.success('个人信息更新成功');
|
||||
setEditing(false);
|
||||
await loadUser();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message ?? '更新失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 校验未通过
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
const v = await passwordForm.validateFields();
|
||||
if (!user) return;
|
||||
await usersService.changePassword(user.id, {
|
||||
oldPassword: v.oldPassword,
|
||||
newPassword: v.newPassword,
|
||||
});
|
||||
messageApi.success('密码修改成功');
|
||||
passwordForm.resetFields();
|
||||
} catch (e: unknown) {
|
||||
if ((e as { errorFields?: unknown })?.errorFields) return;
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message ?? '密码修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <Card loading={loading}>加载中...</Card>;
|
||||
}
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: '个人信息',
|
||||
children: (
|
||||
<div className="profile-content">
|
||||
<Form
|
||||
form={infoForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
|
||||
<div className="profile-avatar-wrapper">
|
||||
{(user.realName || user.username) ? (
|
||||
<Avatar
|
||||
size={100}
|
||||
style={{ backgroundColor: '#4E9B6E' }}
|
||||
>
|
||||
{(user.realName || user.username)![0]}
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar
|
||||
size={100}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: '#4E9B6E' }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: 'rgba(0, 0, 0, 0.65)',
|
||||
}}
|
||||
>
|
||||
头像
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="用户名">
|
||||
<span className="profile-field-text">{user.username}</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="真实姓名"
|
||||
rules={[{ max: 50, message: '真实姓名不能超过50个字符' }]}
|
||||
>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name="realName"
|
||||
noStyle
|
||||
rules={[{ max: 50, message: '真实姓名不能超过50个字符' }]}
|
||||
>
|
||||
<Input placeholder="请输入真实姓名" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="profile-field-text">
|
||||
{user.realName ?? '-'}
|
||||
</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name="email"
|
||||
noStyle
|
||||
rules={[
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="profile-field-text">
|
||||
{user.email ?? '-'}
|
||||
</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="注册时间">
|
||||
<span className="profile-field-text">
|
||||
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="使用天数">
|
||||
<span className="profile-field-text">
|
||||
{calculateUsageDays(user.createdAt)}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
|
||||
{editing ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleUpdateInfo}>
|
||||
更新个人信息
|
||||
</Button>
|
||||
<Button onClick={handleCancelEdit}>取消</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Button type="primary" onClick={handleEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: '修改密码',
|
||||
children: (
|
||||
<div className="profile-content">
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
style={{ maxWidth: 600 }}
|
||||
onFinish={handleChangePassword}
|
||||
>
|
||||
<Form.Item
|
||||
name="oldPassword"
|
||||
label="原密码"
|
||||
rules={[{ required: true, message: '请输入原密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入原密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度至少6位' },
|
||||
{ max: 100, message: '密码不能超过100位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="info" items={tabItems} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
export { default } from './ProfilePage';
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Modal,
|
||||
Descriptions,
|
||||
Avatar,
|
||||
Tag,
|
||||
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 {
|
||||
open: boolean;
|
||||
user: User | null;
|
||||
/** 是否展示删除按钮(仅超级管理员可删除已冻结且非超管用户) */
|
||||
canDelete?: boolean;
|
||||
onCancel: () => void;
|
||||
onDelete?: (id: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function UserDetailModal({
|
||||
open,
|
||||
user,
|
||||
canDelete = false,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: UserDetailModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
const showDelete = canDelete && !!onDelete;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="用户详情"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="close" onClick={onCancel}>
|
||||
关闭
|
||||
</Button>,
|
||||
showDelete && (
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定要删除这个用户吗?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => onDelete?.(user.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button key="delete" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
width={640}
|
||||
>
|
||||
<div style={{ marginBottom: 24, textAlign: 'center' }}>
|
||||
<Space size={16} align="center">
|
||||
{(user.realName || user.username) ? (
|
||||
<Avatar
|
||||
size={60}
|
||||
style={{ backgroundColor: '#4E9B6E' }}
|
||||
>
|
||||
{(user.realName || user.username)![0]}
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar
|
||||
size={60}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: '#4E9B6E' }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
}}
|
||||
>
|
||||
{user.realName || user.username}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Descriptions column={2} bordered>
|
||||
<Descriptions.Item label="用户ID">{user.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">
|
||||
{user.email ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="真实姓名">
|
||||
{user.realName ?? '-'}
|
||||
</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>
|
||||
<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>
|
||||
{user.lastLoginIp && (
|
||||
<Descriptions.Item label="最后登录IP" span={2}>
|
||||
{user.lastLoginIp}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.user-page .user-search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-page .user-search-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Avatar,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
App as AntdApp,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usersService } from '@/services/users';
|
||||
import { authService } from '@/services/auth';
|
||||
import type { User, QueryUserRequest } from '@/types/user';
|
||||
import {
|
||||
USER_ROLE_OPTIONS,
|
||||
USER_STATUS_OPTIONS,
|
||||
getRoleText,
|
||||
getStatusText,
|
||||
} from '@/types/user';
|
||||
import UserDetailModal from './UserDetailModal';
|
||||
import dayjs from 'dayjs';
|
||||
import './UserPage.css';
|
||||
|
||||
export default function 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 [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const queryRef = useRef<QueryUserRequest>({});
|
||||
|
||||
const loadData = async (params?: QueryUserRequest, resetPage = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const basePage = resetPage ? 1 : pagination.current;
|
||||
const baseSize = pagination.pageSize;
|
||||
const query: QueryUserRequest = {
|
||||
page: params?.page ?? basePage,
|
||||
limit: params?.limit ?? baseSize,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
...queryRef.current,
|
||||
...params,
|
||||
};
|
||||
const res = await usersService.getList(query);
|
||||
setUsers(res.list);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: res.pagination.current_page,
|
||||
pageSize: res.pagination.page_size,
|
||||
total: res.pagination.total,
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message || '加载用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData({}, true);
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
const v = form.getFieldsValue();
|
||||
queryRef.current = {
|
||||
username: v.username || undefined,
|
||||
realName: v.realName || undefined,
|
||||
email: v.email || undefined,
|
||||
role: v.role || undefined,
|
||||
status: v.status || undefined,
|
||||
};
|
||||
loadData(queryRef.current, true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
queryRef.current = {};
|
||||
loadData({}, true);
|
||||
};
|
||||
|
||||
const currentUser = authService.getUser();
|
||||
const isSuperAdmin = currentUser?.role === 'super_admin';
|
||||
|
||||
const handleView = (record: User) => {
|
||||
setSelectedUser(record);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
try {
|
||||
const next = user.status === 'active' ? 'inactive' : 'active';
|
||||
if (next === 'inactive') {
|
||||
await authService.freezeUser(user.id);
|
||||
} else {
|
||||
await authService.activateUser(user.id);
|
||||
}
|
||||
messageApi.success(next === 'inactive' ? '用户已冻结' : '用户已解冻');
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await usersService.deleteUser(id);
|
||||
messageApi.success('删除成功');
|
||||
setDetailOpen(false);
|
||||
setSelectedUser(null);
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
messageApi.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
width: 72,
|
||||
render: (_: unknown, r: User) => (
|
||||
<Avatar
|
||||
size={32}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: '#4E9B6E' }}
|
||||
>
|
||||
{(r.realName || r.username)?.[0]}
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '真实姓名',
|
||||
dataIndex: 'realName',
|
||||
key: 'realName',
|
||||
width: 120,
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 180,
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'lastLoginAt',
|
||||
key: 'lastLoginAt',
|
||||
width: 160,
|
||||
render: (d: string | null) =>
|
||||
d ? dayjs(d).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => (
|
||||
<Tag
|
||||
color={
|
||||
s === 'active'
|
||||
? 'success'
|
||||
: s === 'inactive'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
>
|
||||
{getStatusText(s)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 110,
|
||||
render: (r: string) => (
|
||||
<Tag
|
||||
color={
|
||||
r === 'super_admin' ? 'red' : r === 'admin' ? 'orange' : 'blue'
|
||||
}
|
||||
>
|
||||
{getRoleText(r)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_: unknown, record: User) => {
|
||||
const frozen = record.status === 'inactive';
|
||||
const superAdmin = record.role === 'super_admin';
|
||||
return (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{!superAdmin && (
|
||||
<>
|
||||
{frozen ? (
|
||||
<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="realName" 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="role" label="角色" initialValue="">
|
||||
<Select
|
||||
placeholder="请选择角色"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{USER_ROLE_OPTIONS.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="status" label="状态" initialValue="">
|
||||
<Select
|
||||
placeholder="请选择状态"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{USER_STATUS_OPTIONS.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.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<User>
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
const nextSize = pageSize || 10;
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: nextSize,
|
||||
}));
|
||||
loadData({ page, limit: nextSize });
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<UserDetailModal
|
||||
open={detailOpen}
|
||||
user={selectedUser}
|
||||
canDelete={
|
||||
!!selectedUser &&
|
||||
selectedUser.status === 'inactive' &&
|
||||
selectedUser.role !== 'super_admin' &&
|
||||
isSuperAdmin
|
||||
}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UserPage } from './UserPage';
|
||||
export { default } from './UserPage';
|
||||
@@ -4,8 +4,10 @@ import MainLayout from '../layouts/MainLayout';
|
||||
import ProtectedRoute from '../components/ProtectedRoute';
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
|
||||
const WordsPage = lazy(() => import('../pages/Words'));
|
||||
const LoginPage = lazy(() => import('../pages/LoginPage'));
|
||||
const WordsPage = lazy(() => import('../pages/words'));
|
||||
const UsersPage = lazy(() => import('../pages/users'));
|
||||
const ProfilePage = lazy(() => import('../pages/profile'));
|
||||
const LoginPage = lazy(() => import('../pages/login'));
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -30,6 +32,14 @@ export const router = createBrowserRouter([
|
||||
path: 'words',
|
||||
element: <WordsPage />,
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: <UsersPage />,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: <ProfilePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,13 +16,14 @@ export interface UserInfo {
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录请求参数
|
||||
* 登录请求参数(与 API LoginDto 一致)
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
usernameOrEmail: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -42,7 +43,6 @@ class AuthService {
|
||||
* 登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
// TODO: 根据实际 API 调整
|
||||
const response = await api.post<LoginResponse>('/auth/login', credentials);
|
||||
this.setToken(response.accessToken);
|
||||
this.setUser(response.user);
|
||||
@@ -120,6 +120,22 @@ class AuthService {
|
||||
const user = this.getUser();
|
||||
return user?.role === 'admin' || user?.role === 'super_admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结用户(将状态设为 inactive)
|
||||
* 需要管理员权限,调用 PATCH /users/:id
|
||||
*/
|
||||
async freezeUser(id: number): Promise<UserInfo> {
|
||||
return api.patch<UserInfo>(`/users/${id}`, { status: 'inactive' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活用户(将状态设为 active)
|
||||
* 需要管理员权限,调用 PATCH /users/:id
|
||||
*/
|
||||
async activateUser(id: number): Promise<UserInfo> {
|
||||
return api.patch<UserInfo>(`/users/${id}`, { status: 'active' });
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from './api';
|
||||
import type { User, QueryUserRequest, PaginatedUserResponse } from '@/types/user';
|
||||
|
||||
/**
|
||||
* 用户管理服务(列表、详情、删除、更新、改密;冻结/激活见 authService)
|
||||
*/
|
||||
class UsersService {
|
||||
async getList(params: QueryUserRequest): Promise<PaginatedUserResponse> {
|
||||
return api.get<PaginatedUserResponse>('/users', { params });
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<User> {
|
||||
return api.get<User>(`/users/${id}`);
|
||||
}
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
return api.delete(`/users/${id}`);
|
||||
}
|
||||
|
||||
/** 更新用户信息(个人中心用:email、realName) */
|
||||
async updateUser(
|
||||
id: number,
|
||||
data: { email?: string; realName?: string },
|
||||
): Promise<User> {
|
||||
return api.patch<User>(`/users/${id}`, data);
|
||||
}
|
||||
|
||||
/** 修改密码 */
|
||||
async changePassword(
|
||||
id: number,
|
||||
data: { oldPassword: string; newPassword: string },
|
||||
): Promise<void> {
|
||||
return api.patch(`/users/${id}/password`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const usersService = new UsersService();
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 用户管理相关类型(与 API 一致,无头像上传)
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
realName: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
lastLoginIp: string | null;
|
||||
}
|
||||
|
||||
export interface QueryUserRequest {
|
||||
username?: string;
|
||||
realName?: string;
|
||||
email?: 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: 'admin' },
|
||||
{ label: '超级管理员', value: 'super_admin' },
|
||||
] as const;
|
||||
|
||||
export const USER_STATUS_OPTIONS = [
|
||||
{ label: '不限', value: '' },
|
||||
{ label: '活跃', value: 'active' },
|
||||
{ label: '冻结', value: 'inactive' },
|
||||
] as const;
|
||||
|
||||
export function getRoleText(role: string): string {
|
||||
const o = USER_ROLE_OPTIONS.find((opt) => opt.value === role);
|
||||
return o ? o.label : role;
|
||||
}
|
||||
|
||||
export function getStatusText(status: string): string {
|
||||
const o = USER_STATUS_OPTIONS.find((opt) => opt.value === status);
|
||||
return o ? o.label : status;
|
||||
}
|
||||
Reference in New Issue
Block a user