feat(admin): add user management and system settings pages
Backend (user-service): - Add admin user management APIs (list, search, statistics, detail) - Add pagination and filtering support for user queries - Add JWT token authentication for admin endpoints Frontend (admin-client): - Add UsersPage with user list, search, filters and statistics - Add SettingsPage with admin profile, password change, system info - Update App.tsx routes to use new pages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed5dc49b4a
commit
e0c2462017
|
|
@ -6,6 +6,8 @@ import { DashboardPage } from './features/dashboard/presentation/pages/Dashboard
|
|||
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
||||
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
||||
import { UsersPage } from './features/users';
|
||||
import { SettingsPage } from './features/settings';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
|
@ -28,8 +30,8 @@ function App() {
|
|||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
||||
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 未匹配路由重定向 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { SettingsPage } from './presentation/pages/SettingsPage';
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Descriptions,
|
||||
Divider,
|
||||
message,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
SafetyOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../../../../shared/hooks/useAuth';
|
||||
import api from '../../../../shared/utils/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export function SettingsPage() {
|
||||
const { admin, logout } = useAuth();
|
||||
const [changePasswordForm] = Form.useForm();
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
const values = await changePasswordForm.validateFields();
|
||||
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setChangingPassword(true);
|
||||
|
||||
await api.post('/admin/change-password', {
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
|
||||
message.success('密码修改成功,请重新登录');
|
||||
setPasswordModalOpen(false);
|
||||
changePasswordForm.resetFields();
|
||||
|
||||
// Logout after password change
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
}, 1500);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { message?: string } } };
|
||||
message.error(err.response?.data?.message || '密码修改失败');
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const systemInfo = {
|
||||
version: '1.0.0',
|
||||
environment: import.meta.env.MODE,
|
||||
apiBase: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
buildTime: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">系统设置</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Admin Profile */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<UserOutlined className="mr-2" />
|
||||
管理员信息
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="用户名">
|
||||
{admin?.username || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">
|
||||
{admin?.name || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{admin?.role || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权限">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{admin?.permissions?.map((p: string) => (
|
||||
<span
|
||||
key={p}
|
||||
className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
)) || '-'}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<SafetyOutlined className="mr-2" />
|
||||
安全设置
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Text type="secondary">
|
||||
定期修改密码可以提高账户安全性
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<LockOutlined />}
|
||||
onClick={() => setPasswordModalOpen(true)}
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="mb-4">
|
||||
<Text type="secondary">
|
||||
退出登录后需要重新输入账号密码
|
||||
</Text>
|
||||
</div>
|
||||
<Button danger onClick={logout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* System Info */}
|
||||
<Col xs={24}>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<InfoCircleOutlined className="mr-2" />
|
||||
系统信息
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row gutter={[32, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="text-gray-500 text-sm mb-1">系统版本</div>
|
||||
<div className="font-medium">{systemInfo.version}</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="text-gray-500 text-sm mb-1">运行环境</div>
|
||||
<div className="font-medium">{systemInfo.environment}</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="text-gray-500 text-sm mb-1">API 地址</div>
|
||||
<div className="font-medium text-xs break-all">
|
||||
{systemInfo.apiBase}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="text-gray-500 text-sm mb-1">构建日期</div>
|
||||
<div className="font-medium">{systemInfo.buildTime}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={passwordModalOpen}
|
||||
onOk={handleChangePassword}
|
||||
onCancel={() => {
|
||||
setPasswordModalOpen(false);
|
||||
changePasswordForm.resetFields();
|
||||
}}
|
||||
confirmLoading={changingPassword}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={changePasswordForm}
|
||||
layout="vertical"
|
||||
className="mt-4"
|
||||
>
|
||||
<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位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认新密码"
|
||||
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>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './useUsers';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usersApi, UserQueryParams, UserDto, PaginatedUsers, UserStatistics } from '../infrastructure/users.api';
|
||||
|
||||
// User list query
|
||||
export function useUsers(params: UserQueryParams) {
|
||||
return useQuery<PaginatedUsers>({
|
||||
queryKey: ['users', 'list', params],
|
||||
queryFn: () => usersApi.listUsers(params),
|
||||
});
|
||||
}
|
||||
|
||||
// User statistics query
|
||||
export function useUserStatistics() {
|
||||
return useQuery<UserStatistics>({
|
||||
queryKey: ['users', 'statistics'],
|
||||
queryFn: () => usersApi.getStatistics(),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
}
|
||||
|
||||
// User search query
|
||||
export function useUserSearch(keyword: string, limit = 10, enabled = true) {
|
||||
return useQuery<UserDto[]>({
|
||||
queryKey: ['users', 'search', keyword, limit],
|
||||
queryFn: () => usersApi.searchUsers(keyword, limit),
|
||||
enabled: enabled && keyword.length >= 2,
|
||||
});
|
||||
}
|
||||
|
||||
// User detail query
|
||||
export function useUserDetail(id: string, enabled = true) {
|
||||
return useQuery<UserDto>({
|
||||
queryKey: ['users', 'detail', id],
|
||||
queryFn: () => usersApi.getUserDetail(id),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { UserDto, UserType, PaginatedUsers, UserStatistics, UserQueryParams } from '../infrastructure/users.api';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './application';
|
||||
export * from './infrastructure';
|
||||
export { UsersPage } from './presentation/pages/UsersPage';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './users.api';
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import api from '../../../shared/utils/api';
|
||||
|
||||
// ==================== DTOs ====================
|
||||
|
||||
export type UserType = 'ANONYMOUS' | 'REGISTERED';
|
||||
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
type: UserType;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
fingerprint?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lastActiveAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedUsers {
|
||||
items: UserDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface UserStatistics {
|
||||
total: number;
|
||||
anonymous: number;
|
||||
registered: number;
|
||||
registrationRate: string;
|
||||
}
|
||||
|
||||
export interface UserQueryParams {
|
||||
type?: UserType;
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'createdAt' | 'lastActiveAt';
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
export const usersApi = {
|
||||
// List users with pagination
|
||||
listUsers: async (params: UserQueryParams): Promise<PaginatedUsers> => {
|
||||
const response = await api.get('/users/admin/list', { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get user statistics
|
||||
getStatistics: async (): Promise<UserStatistics> => {
|
||||
const response = await api.get('/users/admin/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Search users
|
||||
searchUsers: async (keyword: string, limit = 10): Promise<UserDto[]> => {
|
||||
const response = await api.get('/users/admin/search', {
|
||||
params: { keyword, limit },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get user detail
|
||||
getUserDetail: async (id: string): Promise<UserDto> => {
|
||||
const response = await api.get(`/users/admin/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Select,
|
||||
Input,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Typography,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Avatar,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
UserAddOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
useUsers,
|
||||
useUserStatistics,
|
||||
useUserDetail,
|
||||
type UserDto,
|
||||
type UserType,
|
||||
} from '../../application';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const TYPE_COLORS: Record<UserType, string> = {
|
||||
ANONYMOUS: 'default',
|
||||
REGISTERED: 'green',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<UserType, string> = {
|
||||
ANONYMOUS: '匿名用户',
|
||||
REGISTERED: '注册用户',
|
||||
};
|
||||
|
||||
export function UsersPage() {
|
||||
const [filters, setFilters] = useState<{
|
||||
type?: UserType;
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy: 'createdAt' | 'lastActiveAt';
|
||||
sortOrder: 'ASC' | 'DESC';
|
||||
}>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: usersData, isLoading: loadingUsers } = useUsers(filters);
|
||||
const { data: stats, isLoading: loadingStats } = useUserStatistics();
|
||||
const { data: userDetail, isLoading: loadingDetail } = useUserDetail(
|
||||
selectedUserId || '',
|
||||
!!selectedUserId && detailDrawerOpen
|
||||
);
|
||||
|
||||
const showUserDetail = (user: UserDto) => {
|
||||
setSelectedUserId(user.id);
|
||||
setDetailDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
if (value.length >= 2) {
|
||||
// Search by phone or nickname
|
||||
if (/^\d+$/.test(value)) {
|
||||
setFilters((prev) => ({ ...prev, phone: value, nickname: undefined, page: 1 }));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, nickname: value, phone: undefined, page: 1 }));
|
||||
}
|
||||
} else if (value === '') {
|
||||
setFilters((prev) => ({ ...prev, phone: undefined, nickname: undefined, page: 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: { current?: number; pageSize?: number }) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current || 1,
|
||||
pageSize: pagination.pageSize || 20,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<UserDto> = [
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Avatar
|
||||
size="small"
|
||||
src={record.avatar}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<span>{record.nickname || record.phone || record.id.slice(0, 8)}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type: UserType) => (
|
||||
<Tag color={TYPE_COLORS[type]}>{TYPE_LABELS[type]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 140,
|
||||
render: (phone) => phone || '-',
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '最后活跃',
|
||||
dataIndex: 'lastActiveAt',
|
||||
key: 'lastActiveAt',
|
||||
width: 180,
|
||||
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<a onClick={() => showUserDetail(record)}>详情</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">用户管理</Title>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={stats?.total ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="注册用户"
|
||||
value={stats?.registered ?? 0}
|
||||
prefix={<UserAddOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
注册率: {stats?.registrationRate ?? '0'}%
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="匿名用户"
|
||||
value={stats?.anonymous ?? 0}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#8c8c8c' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space wrap>
|
||||
<Search
|
||||
placeholder="搜索手机号或昵称"
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 220 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Select
|
||||
placeholder="用户类型"
|
||||
allowClear
|
||||
value={filters.type}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, type: value, page: 1 }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'REGISTERED', label: '注册用户' },
|
||||
{ value: 'ANONYMOUS', label: '匿名用户' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filters.sortBy}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, sortBy: value }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'createdAt', label: '注册时间' },
|
||||
{ value: 'lastActiveAt', label: '活跃时间' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filters.sortOrder}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, sortOrder: value }))}
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'DESC', label: '降序' },
|
||||
{ value: 'ASC', label: '升序' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<Spin spinning={loadingUsers}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={usersData?.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: usersData?.page || 1,
|
||||
pageSize: usersData?.pageSize || 20,
|
||||
total: usersData?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* User Detail Drawer */}
|
||||
<Drawer
|
||||
title="用户详情"
|
||||
placement="right"
|
||||
width={480}
|
||||
open={detailDrawerOpen}
|
||||
onClose={() => {
|
||||
setDetailDrawerOpen(false);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
>
|
||||
<Spin spinning={loadingDetail}>
|
||||
{userDetail && (
|
||||
<div>
|
||||
<div className="text-center mb-6">
|
||||
<Avatar
|
||||
size={80}
|
||||
src={userDetail.avatar}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Tag color={TYPE_COLORS[userDetail.type]}>
|
||||
{TYPE_LABELS[userDetail.type]}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="用户ID">
|
||||
<code className="text-xs">{userDetail.id}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">
|
||||
{userDetail.nickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
{userDetail.phone || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备指纹">
|
||||
<code className="text-xs">
|
||||
{userDetail.fingerprint?.slice(0, 16) || '-'}...
|
||||
</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{dayjs(userDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{userDetail.updatedAt
|
||||
? dayjs(userDetail.updatedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后活跃">
|
||||
{dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Param,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { UserService } from '../../application/services/user.service';
|
||||
import { UserType } from '../../domain/entities/user.entity';
|
||||
|
||||
/**
|
||||
* Admin User Management Controller
|
||||
* Provides endpoints for admin dashboard user management
|
||||
*/
|
||||
@Controller('users/admin')
|
||||
export class AdminUserController {
|
||||
private readonly jwtSecret: string;
|
||||
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.jwtSecret =
|
||||
this.configService.get('JWT_SECRET') || 'iconsulting-secret-key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify admin JWT token
|
||||
*/
|
||||
private verifyAdmin(authorization: string): { id: string; username: string } {
|
||||
if (!authorization?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing authorization token');
|
||||
}
|
||||
|
||||
const token = authorization.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.jwtSecret) as {
|
||||
sub: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
return { id: decoded.sub, username: decoded.username };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/admin/list
|
||||
* List users with pagination and filters
|
||||
*/
|
||||
@Get('list')
|
||||
async listUsers(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('type') type?: UserType,
|
||||
@Query('phone') phone?: string,
|
||||
@Query('nickname') nickname?: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
@Query('sortBy') sortBy?: 'createdAt' | 'lastActiveAt',
|
||||
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const result = await this.userService.findAll({
|
||||
type,
|
||||
phone,
|
||||
nickname,
|
||||
page: parseInt(page, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: result.items.map((user) => ({
|
||||
id: user.id,
|
||||
type: user.type,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
createdAt: user.createdAt,
|
||||
lastActiveAt: user.lastActiveAt,
|
||||
})),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
totalPages: result.totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/admin/statistics
|
||||
* Get user statistics
|
||||
*/
|
||||
@Get('statistics')
|
||||
async getUserStatistics(@Headers('authorization') auth: string) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const countByType = await this.userService.countByType();
|
||||
const total =
|
||||
countByType[UserType.ANONYMOUS] + countByType[UserType.REGISTERED];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
anonymous: countByType[UserType.ANONYMOUS],
|
||||
registered: countByType[UserType.REGISTERED],
|
||||
registrationRate:
|
||||
total > 0
|
||||
? ((countByType[UserType.REGISTERED] / total) * 100).toFixed(1)
|
||||
: '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/admin/search
|
||||
* Search users by keyword
|
||||
*/
|
||||
@Get('search')
|
||||
async searchUsers(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('keyword') keyword: string,
|
||||
@Query('limit') limit = '10',
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
if (!keyword || keyword.length < 2) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const users = await this.userService.searchUsers(
|
||||
keyword,
|
||||
parseInt(limit, 10),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: users.map((user) => ({
|
||||
id: user.id,
|
||||
type: user.type,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
createdAt: user.createdAt,
|
||||
lastActiveAt: user.lastActiveAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/admin/:id
|
||||
* Get user detail by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
async getUserDetail(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const user = await this.userService.findById(id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
type: user.type,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
fingerprint: user.fingerprint,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastActiveAt: user.lastActiveAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './user.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './admin-user.controller';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IUserRepository } from '../../../domain/repositories/user.repository.interface';
|
||||
import { UserEntity } from '../../../domain/entities/user.entity';
|
||||
import { Repository, Like, FindOptionsWhere } from 'typeorm';
|
||||
import {
|
||||
IUserRepository,
|
||||
UserQueryOptions,
|
||||
PaginatedUsers,
|
||||
} from '../../../domain/repositories/user.repository.interface';
|
||||
import { UserEntity, UserType } from '../../../domain/entities/user.entity';
|
||||
import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -37,6 +41,73 @@ export class UserPostgresRepository implements IUserRepository {
|
|||
await this.repo.update(userId, { lastActiveAt: new Date() });
|
||||
}
|
||||
|
||||
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
|
||||
const page = options.page || 1;
|
||||
const pageSize = options.pageSize || 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: FindOptionsWhere<UserORM> = {};
|
||||
if (options.type) {
|
||||
where.type = options.type;
|
||||
}
|
||||
if (options.phone) {
|
||||
where.phone = Like(`%${options.phone}%`);
|
||||
}
|
||||
if (options.nickname) {
|
||||
where.nickname = Like(`%${options.nickname}%`);
|
||||
}
|
||||
|
||||
const [items, total] = await this.repo.findAndCount({
|
||||
where,
|
||||
order: {
|
||||
[options.sortBy || 'createdAt']: options.sortOrder || 'DESC',
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((orm) => this.toEntity(orm)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async countByType(): Promise<Record<UserType, number>> {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder('user')
|
||||
.select('user.type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('user.type')
|
||||
.getRawMany();
|
||||
|
||||
const counts: Record<UserType, number> = {
|
||||
[UserType.ANONYMOUS]: 0,
|
||||
[UserType.REGISTERED]: 0,
|
||||
};
|
||||
|
||||
for (const row of result) {
|
||||
counts[row.type as UserType] = parseInt(row.count, 10);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
async search(keyword: string, limit = 10): Promise<UserEntity[]> {
|
||||
const items = await this.repo.find({
|
||||
where: [
|
||||
{ phone: Like(`%${keyword}%`) },
|
||||
{ nickname: Like(`%${keyword}%`) },
|
||||
],
|
||||
take: limit,
|
||||
order: { lastActiveAt: 'DESC' },
|
||||
});
|
||||
|
||||
return items.map((orm) => this.toEntity(orm));
|
||||
}
|
||||
|
||||
private toORM(entity: UserEntity): UserORM {
|
||||
const orm = new UserORM();
|
||||
orm.id = entity.id;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { UserEntity } from '../../domain/entities/user.entity';
|
||||
import { UserEntity, UserType } from '../../domain/entities/user.entity';
|
||||
import {
|
||||
IUserRepository,
|
||||
USER_REPOSITORY,
|
||||
UserQueryOptions,
|
||||
PaginatedUsers,
|
||||
} from '../../domain/repositories/user.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -66,4 +68,17 @@ export class UserService {
|
|||
user.updateProfile(data);
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
|
||||
// Admin methods
|
||||
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
|
||||
return this.userRepo.findAll(options);
|
||||
}
|
||||
|
||||
async countByType(): Promise<Record<UserType, number>> {
|
||||
return this.userRepo.countByType();
|
||||
}
|
||||
|
||||
async searchUsers(keyword: string, limit?: number): Promise<UserEntity[]> {
|
||||
return this.userRepo.search(keyword, limit);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,28 @@
|
|||
import { UserEntity } from '../entities/user.entity';
|
||||
import { UserEntity, UserType } from '../entities/user.entity';
|
||||
|
||||
/**
|
||||
* User query options for admin
|
||||
*/
|
||||
export interface UserQueryOptions {
|
||||
type?: UserType;
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'createdAt' | 'lastActiveAt';
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated result
|
||||
*/
|
||||
export interface PaginatedUsers {
|
||||
items: UserEntity[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Repository Interface
|
||||
|
|
@ -29,6 +53,21 @@ export interface IUserRepository {
|
|||
* Update last active timestamp
|
||||
*/
|
||||
updateLastActive(userId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Find all users with pagination and filters (admin)
|
||||
*/
|
||||
findAll(options: UserQueryOptions): Promise<PaginatedUsers>;
|
||||
|
||||
/**
|
||||
* Count users by type (admin)
|
||||
*/
|
||||
countByType(): Promise<Record<UserType, number>>;
|
||||
|
||||
/**
|
||||
* Search users by keyword (phone or nickname)
|
||||
*/
|
||||
search(keyword: string, limit?: number): Promise<UserEntity[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { UserPostgresRepository } from '../adapters/outbound/persistence/user-po
|
|||
import { USER_REPOSITORY } from '../domain/repositories/user.repository.interface';
|
||||
import { UserService } from '../application/services/user.service';
|
||||
import { UserController } from '../adapters/inbound/user.controller';
|
||||
import { AdminUserController } from '../adapters/inbound/admin-user.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserORM])],
|
||||
controllers: [UserController],
|
||||
controllers: [UserController, AdminUserController],
|
||||
providers: [
|
||||
UserService,
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue