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 { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
||||||
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||||
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
||||||
|
import { UsersPage } from './features/users';
|
||||||
|
import { SettingsPage } from './features/settings';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -28,8 +30,8 @@ function App() {
|
||||||
<Route path="analytics" element={<AnalyticsPage />} />
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="audit" element={<AuditPage />} />
|
<Route path="audit" element={<AuditPage />} />
|
||||||
<Route path="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</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 './user.controller';
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
|
export * from './admin-user.controller';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, Like, FindOptionsWhere } from 'typeorm';
|
||||||
import { IUserRepository } from '../../../domain/repositories/user.repository.interface';
|
import {
|
||||||
import { UserEntity } from '../../../domain/entities/user.entity';
|
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';
|
import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -37,6 +41,73 @@ export class UserPostgresRepository implements IUserRepository {
|
||||||
await this.repo.update(userId, { lastActiveAt: new Date() });
|
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 {
|
private toORM(entity: UserEntity): UserORM {
|
||||||
const orm = new UserORM();
|
const orm = new UserORM();
|
||||||
orm.id = entity.id;
|
orm.id = entity.id;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity, UserType } from '../../domain/entities/user.entity';
|
||||||
import {
|
import {
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
USER_REPOSITORY,
|
USER_REPOSITORY,
|
||||||
|
UserQueryOptions,
|
||||||
|
PaginatedUsers,
|
||||||
} from '../../domain/repositories/user.repository.interface';
|
} from '../../domain/repositories/user.repository.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -66,4 +68,17 @@ export class UserService {
|
||||||
user.updateProfile(data);
|
user.updateProfile(data);
|
||||||
return this.userRepo.save(user);
|
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
|
* User Repository Interface
|
||||||
|
|
@ -29,6 +53,21 @@ export interface IUserRepository {
|
||||||
* Update last active timestamp
|
* Update last active timestamp
|
||||||
*/
|
*/
|
||||||
updateLastActive(userId: string): Promise<void>;
|
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 { USER_REPOSITORY } from '../domain/repositories/user.repository.interface';
|
||||||
import { UserService } from '../application/services/user.service';
|
import { UserService } from '../application/services/user.service';
|
||||||
import { UserController } from '../adapters/inbound/user.controller';
|
import { UserController } from '../adapters/inbound/user.controller';
|
||||||
|
import { AdminUserController } from '../adapters/inbound/admin-user.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserORM])],
|
imports: [TypeOrmModule.forFeature([UserORM])],
|
||||||
controllers: [UserController],
|
controllers: [UserController, AdminUserController],
|
||||||
providers: [
|
providers: [
|
||||||
UserService,
|
UserService,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue