diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx index 25782c9..d2f65aa 100644 --- a/packages/admin-client/src/App.tsx +++ b/packages/admin-client/src/App.tsx @@ -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() { } /> } /> } /> - 用户管理(开发中)} /> - 系统设置(开发中)} /> + } /> + } /> {/* 未匹配路由重定向 */} diff --git a/packages/admin-client/src/features/settings/index.ts b/packages/admin-client/src/features/settings/index.ts new file mode 100644 index 0000000..a27495e --- /dev/null +++ b/packages/admin-client/src/features/settings/index.ts @@ -0,0 +1 @@ +export { SettingsPage } from './presentation/pages/SettingsPage'; diff --git a/packages/admin-client/src/features/settings/presentation/pages/SettingsPage.tsx b/packages/admin-client/src/features/settings/presentation/pages/SettingsPage.tsx new file mode 100644 index 0000000..bff60fb --- /dev/null +++ b/packages/admin-client/src/features/settings/presentation/pages/SettingsPage.tsx @@ -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 ( +
+ 系统设置 + + + {/* Admin Profile */} + + + + 管理员信息 + + } + > + + + {admin?.username || '-'} + + + {admin?.name || '-'} + + + {admin?.role || '-'} + + +
+ {admin?.permissions?.map((p: string) => ( + + {p} + + )) || '-'} +
+
+
+
+ + + {/* Security Settings */} + + + + 安全设置 + + } + > +
+ + 定期修改密码可以提高账户安全性 + +
+ + + + +
+ + 退出登录后需要重新输入账号密码 + +
+ +
+ + + {/* System Info */} + + + + 系统信息 + + } + > + + +
系统版本
+
{systemInfo.version}
+ + +
运行环境
+
{systemInfo.environment}
+ + +
API 地址
+
+ {systemInfo.apiBase} +
+ + +
构建日期
+
{systemInfo.buildTime}
+ +
+
+ +
+ + {/* Change Password Modal */} + { + setPasswordModalOpen(false); + changePasswordForm.resetFields(); + }} + confirmLoading={changingPassword} + okText="确认修改" + cancelText="取消" + > +
+ + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('newPassword') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + + +
+
+
+ ); +} diff --git a/packages/admin-client/src/features/users/application/index.ts b/packages/admin-client/src/features/users/application/index.ts new file mode 100644 index 0000000..c9d98e6 --- /dev/null +++ b/packages/admin-client/src/features/users/application/index.ts @@ -0,0 +1 @@ +export * from './useUsers'; diff --git a/packages/admin-client/src/features/users/application/useUsers.ts b/packages/admin-client/src/features/users/application/useUsers.ts new file mode 100644 index 0000000..b38cc5a --- /dev/null +++ b/packages/admin-client/src/features/users/application/useUsers.ts @@ -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({ + queryKey: ['users', 'list', params], + queryFn: () => usersApi.listUsers(params), + }); +} + +// User statistics query +export function useUserStatistics() { + return useQuery({ + 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({ + 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({ + 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'; diff --git a/packages/admin-client/src/features/users/index.ts b/packages/admin-client/src/features/users/index.ts new file mode 100644 index 0000000..6c78e38 --- /dev/null +++ b/packages/admin-client/src/features/users/index.ts @@ -0,0 +1,3 @@ +export * from './application'; +export * from './infrastructure'; +export { UsersPage } from './presentation/pages/UsersPage'; diff --git a/packages/admin-client/src/features/users/infrastructure/index.ts b/packages/admin-client/src/features/users/infrastructure/index.ts new file mode 100644 index 0000000..12a72be --- /dev/null +++ b/packages/admin-client/src/features/users/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './users.api'; diff --git a/packages/admin-client/src/features/users/infrastructure/users.api.ts b/packages/admin-client/src/features/users/infrastructure/users.api.ts new file mode 100644 index 0000000..9e00ee8 --- /dev/null +++ b/packages/admin-client/src/features/users/infrastructure/users.api.ts @@ -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 => { + const response = await api.get('/users/admin/list', { params }); + return response.data.data; + }, + + // Get user statistics + getStatistics: async (): Promise => { + const response = await api.get('/users/admin/statistics'); + return response.data.data; + }, + + // Search users + searchUsers: async (keyword: string, limit = 10): Promise => { + const response = await api.get('/users/admin/search', { + params: { keyword, limit }, + }); + return response.data.data; + }, + + // Get user detail + getUserDetail: async (id: string): Promise => { + const response = await api.get(`/users/admin/${id}`); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx b/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx new file mode 100644 index 0000000..4bc9bbe --- /dev/null +++ b/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx @@ -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 = { + ANONYMOUS: 'default', + REGISTERED: 'green', +}; + +const TYPE_LABELS: Record = { + 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(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 = [ + { + title: '用户', + key: 'user', + render: (_, record) => ( + + } + /> + {record.nickname || record.phone || record.id.slice(0, 8)} + + ), + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 100, + render: (type: UserType) => ( + {TYPE_LABELS[type]} + ), + }, + { + 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) => ( + showUserDetail(record)}>详情 + ), + }, + ]; + + return ( +
+ 用户管理 + + {/* Statistics Cards */} + + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + + + } + valueStyle={{ color: '#52c41a' }} + /> +
+ 注册率: {stats?.registrationRate ?? '0'}% +
+
+
+ + + + + } + valueStyle={{ color: '#8c8c8c' }} + /> + + + +
+ + {/* Filters */} + + + setSearchKeyword(e.target.value)} + onSearch={handleSearch} + style={{ width: 220 }} + prefix={} + /> + setFilters((prev) => ({ ...prev, sortBy: value }))} + style={{ width: 120 }} + options={[ + { value: 'createdAt', label: '注册时间' }, + { value: 'lastActiveAt', label: '活跃时间' }, + ]} + /> +