Skip to content

Commit d5a0d3e

Browse files
committed
add user manage
1 parent 9a37ddb commit d5a0d3e

5 files changed

Lines changed: 327 additions & 0 deletions

File tree

src/components/sider.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,12 @@ const SiderMenu = ({ selectedKeys }: SiderMenuProps) => {
258258
<Link to={rootRouterPath.adminConfig}>动态配置</Link>
259259
),
260260
},
261+
{
262+
key: 'admin-users',
263+
label: (
264+
<Link to={rootRouterPath.adminUsers}>用户管理</Link>
265+
),
266+
},
261267
],
262268
},
263269
]

src/pages/admin-users.tsx

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { EditOutlined, SearchOutlined } from '@ant-design/icons';
2+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3+
import {
4+
Button,
5+
Card,
6+
DatePicker,
7+
Form,
8+
Input,
9+
message,
10+
Modal,
11+
Select,
12+
Space,
13+
Spin,
14+
Table,
15+
Typography,
16+
} from 'antd';
17+
import dayjs from 'dayjs';
18+
import { useEffect, useRef, useState } from 'react';
19+
import { JSONEditor, type Content, type OnChange } from 'vanilla-jsoneditor';
20+
import { api } from '@/services/api';
21+
22+
const { Title } = Typography;
23+
24+
// JSON Editor wrapper component for quota editing
25+
const JsonEditorWrapper = ({
26+
value,
27+
onChange,
28+
}: {
29+
value: string;
30+
onChange: (value: string) => void;
31+
}) => {
32+
const containerRef = useRef<HTMLDivElement>(null);
33+
const editorRef = useRef<JSONEditor | null>(null);
34+
35+
useEffect(() => {
36+
if (containerRef.current && !editorRef.current) {
37+
const handleChange: OnChange = (
38+
content: Content,
39+
_previousContent: Content,
40+
{ contentErrors },
41+
) => {
42+
if (!contentErrors) {
43+
if ('json' in content && content.json !== undefined) {
44+
onChange(JSON.stringify(content.json, null, 2));
45+
} else if ('text' in content) {
46+
onChange(content.text);
47+
}
48+
}
49+
};
50+
51+
editorRef.current = new JSONEditor({
52+
target: containerRef.current,
53+
props: {
54+
content: { text: value },
55+
onChange: handleChange,
56+
mode: 'text',
57+
},
58+
});
59+
}
60+
61+
return () => {
62+
if (editorRef.current) {
63+
editorRef.current.destroy();
64+
editorRef.current = null;
65+
}
66+
};
67+
}, []);
68+
69+
useEffect(() => {
70+
if (editorRef.current) {
71+
editorRef.current.update({ text: value });
72+
}
73+
}, [value]);
74+
75+
return <div ref={containerRef} style={{ height: 200 }} />;
76+
};
77+
78+
export const Component = () => {
79+
const queryClient = useQueryClient();
80+
const [searchKeyword, setSearchKeyword] = useState('');
81+
const [debouncedSearch, setDebouncedSearch] = useState('');
82+
const [isModalOpen, setIsModalOpen] = useState(false);
83+
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
84+
const [form] = Form.useForm();
85+
const [quotaValue, setQuotaValue] = useState('');
86+
87+
// Debounce search
88+
useEffect(() => {
89+
const timer = setTimeout(() => setDebouncedSearch(searchKeyword), 300);
90+
return () => clearTimeout(timer);
91+
}, [searchKeyword]);
92+
93+
const { data, isLoading } = useQuery({
94+
queryKey: ['adminUsers', debouncedSearch],
95+
queryFn: () => api.searchUsers(debouncedSearch || undefined),
96+
});
97+
98+
const updateMutation = useMutation({
99+
mutationFn: ({ id, data }: { id: number; data: Partial<AdminUser> }) =>
100+
api.updateUser(id, data),
101+
onSuccess: () => {
102+
message.success('用户信息已更新');
103+
setIsModalOpen(false);
104+
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
105+
},
106+
onError: (error) => {
107+
message.error((error as Error).message);
108+
},
109+
});
110+
111+
const handleEdit = (record: AdminUser) => {
112+
setEditingUser(record);
113+
form.setFieldsValue({
114+
tier: record.tier,
115+
status: record.status,
116+
tierExpiresAt: record.tierExpiresAt ? dayjs(record.tierExpiresAt) : null,
117+
});
118+
setQuotaValue(record.quota ? JSON.stringify(record.quota, null, 2) : '');
119+
setIsModalOpen(true);
120+
};
121+
122+
const handleSave = async () => {
123+
try {
124+
const values = await form.validateFields();
125+
if (!editingUser) return;
126+
127+
const updateData: Partial<AdminUser> = {
128+
tier: values.tier,
129+
status: values.status,
130+
tierExpiresAt: values.tierExpiresAt
131+
? values.tierExpiresAt.toISOString()
132+
: null,
133+
};
134+
135+
// Parse quota if provided
136+
if (quotaValue.trim()) {
137+
try {
138+
updateData.quota = JSON.parse(quotaValue);
139+
} catch {
140+
message.error('Quota 格式无效,请输入有效的 JSON');
141+
return;
142+
}
143+
} else {
144+
updateData.quota = null;
145+
}
146+
147+
updateMutation.mutate({ id: editingUser.id, data: updateData });
148+
} catch (error) {
149+
message.error((error as Error).message);
150+
}
151+
};
152+
153+
const columns = [
154+
{
155+
title: 'ID',
156+
dataIndex: 'id',
157+
key: 'id',
158+
width: 80,
159+
},
160+
{
161+
title: '邮箱',
162+
dataIndex: 'email',
163+
key: 'email',
164+
},
165+
{
166+
title: '用户名',
167+
dataIndex: 'name',
168+
key: 'name',
169+
},
170+
{
171+
title: '状态',
172+
dataIndex: 'status',
173+
key: 'status',
174+
width: 100,
175+
render: (status: string) => (
176+
<span className={status === 'normal' ? 'text-green-600' : 'text-orange-500'}>
177+
{status === 'normal' ? '正常' : '未验证'}
178+
</span>
179+
),
180+
},
181+
{
182+
title: '套餐',
183+
dataIndex: 'tier',
184+
key: 'tier',
185+
width: 100,
186+
},
187+
{
188+
title: '套餐过期时间',
189+
dataIndex: 'tierExpiresAt',
190+
key: 'tierExpiresAt',
191+
width: 180,
192+
render: (date: string | null) =>
193+
date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
194+
},
195+
{
196+
title: '自定义配额',
197+
dataIndex: 'quota',
198+
key: 'quota',
199+
width: 100,
200+
render: (quota: Quota | null) => (quota ? '有' : '-'),
201+
},
202+
{
203+
title: '操作',
204+
key: 'action',
205+
width: 80,
206+
render: (_: unknown, record: AdminUser) => (
207+
<Button
208+
type="link"
209+
icon={<EditOutlined />}
210+
onClick={() => handleEdit(record)}
211+
>
212+
编辑
213+
</Button>
214+
),
215+
},
216+
];
217+
218+
return (
219+
<div className="p-6">
220+
<Card>
221+
<div className="flex justify-between items-center mb-4">
222+
<Title level={4} className="m-0!">
223+
用户管理
224+
</Title>
225+
<Input
226+
placeholder="搜索用户名或邮箱"
227+
prefix={<SearchOutlined />}
228+
value={searchKeyword}
229+
onChange={(e) => setSearchKeyword(e.target.value)}
230+
style={{ width: 300 }}
231+
allowClear
232+
/>
233+
</div>
234+
235+
<Spin spinning={isLoading}>
236+
<Table
237+
dataSource={data?.data || []}
238+
columns={columns}
239+
rowKey="id"
240+
pagination={{ pageSize: 20 }}
241+
/>
242+
</Spin>
243+
</Card>
244+
245+
<Modal
246+
title={`编辑用户: ${editingUser?.email}`}
247+
open={isModalOpen}
248+
onCancel={() => setIsModalOpen(false)}
249+
footer={[
250+
<Button key="cancel" onClick={() => setIsModalOpen(false)}>
251+
取消
252+
</Button>,
253+
<Button
254+
key="save"
255+
type="primary"
256+
loading={updateMutation.isPending}
257+
onClick={handleSave}
258+
>
259+
保存
260+
</Button>,
261+
]}
262+
width={600}
263+
>
264+
<Form form={form} layout="vertical" className="mt-4">
265+
<Space className="w-full" direction="vertical" size="middle">
266+
<Form.Item name="tier" label="套餐" className="mb-0!">
267+
<Select
268+
options={[
269+
{ value: 'free', label: '免费版' },
270+
{ value: 'standard', label: '标准版' },
271+
{ value: 'premium', label: '高级版' },
272+
{ value: 'pro', label: '专业版' },
273+
{ value: 'custom', label: '定制版' },
274+
]}
275+
/>
276+
</Form.Item>
277+
<Form.Item name="status" label="状态" className="mb-0!">
278+
<Select
279+
options={[
280+
{ value: 'normal', label: '正常' },
281+
{ value: 'unverified', label: '未验证' },
282+
]}
283+
/>
284+
</Form.Item>
285+
<Form.Item name="tierExpiresAt" label="套餐过期时间" className="mb-0!">
286+
<DatePicker showTime className="w-full" />
287+
</Form.Item>
288+
<Form.Item label="自定义配额 (JSON,留空则使用默认配额)" className="mb-0!">
289+
<JsonEditorWrapper value={quotaValue} onChange={setQuotaValue} />
290+
</Form.Item>
291+
</Space>
292+
</Form>
293+
</Modal>
294+
</div>
295+
);
296+
};

src/router.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const rootRouterPath = {
1717
register: '/register',
1818
auditLogs: '/audit-logs',
1919
adminConfig: '/admin-config',
20+
adminUsers: '/admin-users',
2021
};
2122

2223
export const needAuthLoader = ({ request }: { request: Request }) => {
@@ -94,6 +95,11 @@ export const router = createHashRouter([
9495
loader: needAuthLoader,
9596
lazy: () => import('./pages/admin-config'),
9697
},
98+
{
99+
path: 'admin-users',
100+
loader: needAuthLoader,
101+
lazy: () => import('./pages/admin-users'),
102+
},
97103
],
98104
},
99105
]);

src/services/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,12 @@ export const api = {
196196
value,
197197
}),
198198
deleteAdminConfig: (key: string) => request('delete', `/admin/config/${key}`),
199+
// admin user management
200+
searchUsers: (search?: string) =>
201+
request<{ data: AdminUser[] }>(
202+
'get',
203+
search ? `/admin/users?search=${encodeURIComponent(search)}` : '/admin/users',
204+
),
205+
updateUser: (id: number, data: Partial<AdminUser>) =>
206+
request<AdminUser>('put', `/admin/users/${id}`, data),
199207
};

src/types.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ interface User {
2727
admin?: boolean;
2828
}
2929

30+
interface AdminUser {
31+
id: number;
32+
email: string;
33+
name: string;
34+
status: 'normal' | 'unverified' | null;
35+
tier: string;
36+
tierExpiresAt?: string | null;
37+
quota?: Quota | null;
38+
createdAt?: string;
39+
}
40+
3041
export interface Quota {
3142
base?: Exclude<Tier, 'custom'>;
3243
app: number;

0 commit comments

Comments
 (0)