feat:生文

This commit is contained in:
R524809
2026-01-30 16:04:10 +08:00
parent d4fc8f44ee
commit e7b4a948ac
63 changed files with 981954 additions and 1124 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ function App() {
locale={zhCN}
theme={{
token: {
colorPrimary: '#93D333',
colorPrimary: '#4E9B6E',
borderRadius: 8,
},
}}
+1 -1
View File
@@ -21,7 +21,7 @@
.error-icon {
font-size: 80px;
color: #93D333;
color: #4E9B6E;
margin-bottom: 24px;
animation: pulse 2s ease-in-out infinite;
}
+1 -1
View File
@@ -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' }} />
)}
+1 -1
View File
@@ -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;
}
+9 -2
View File
@@ -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}
+2
View File
@@ -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]);
+28 -16
View File
@@ -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,
// },
];
/**
-68
View File
@@ -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;
+95 -93
View File
@@ -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;
}
+86
View File
@@ -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>
);
}
+1
View File
@@ -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>
);
}
+2
View File
@@ -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>
);
}
+7
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;
}
+407
View File
@@ -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>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as UserPage } from './UserPage';
export { default } from './UserPage';
+12 -2
View File
@@ -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 />,
},
],
},
{
+19 -3
View File
@@ -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' });
}
}
// 导出单例
+37
View File
@@ -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();
+60
View File
@@ -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;
}