Skip to content

Commit 9a37ddb

Browse files
committed
add admin config
1 parent 13d0e6a commit 9a37ddb

File tree

7 files changed

+375
-64
lines changed

7 files changed

+375
-64
lines changed

bun.lock

Lines changed: 82 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@
1010
},
1111
"dependencies": {
1212
"@ant-design/icons": "^6.1.0",
13-
"@rsbuild/core": "^1.6.15",
13+
"@rsbuild/core": "^1.7.2",
1414
"@rsbuild/plugin-react": "^1.4.2",
1515
"@rsbuild/plugin-svgr": "^1.2.3",
16-
"@tanstack/react-query": "^5.90.12",
17-
"antd": "^6.1.1",
16+
"@tanstack/react-query": "^5.90.16",
17+
"antd": "^6.1.4",
1818
"dayjs": "^1.11.19",
1919
"git-url-parse": "^16.1.0",
2020
"hash-wasm": "^4.12.0",
2121
"history": "^5.3.0",
2222
"json-diff-kit": "^1.0.34",
2323
"react": "^19.2.3",
2424
"react-dom": "^19.2.3",
25-
"react-router-dom": "^7.11.0",
25+
"react-router-dom": "^7.12.0",
2626
"ua-parser-js": "^2.0.7",
2727
"vanilla-jsoneditor": "^3.11.0",
2828
"xlsx": "^0.18.5"
2929
},
3030
"devDependencies": {
31-
"@biomejs/biome": "2.3.10",
31+
"@biomejs/biome": "2.3.11",
3232
"@tailwindcss/postcss": "^4.1.18",
3333
"@types/git-url-parse": "^16.0.2",
34-
"@types/node": "^25.0.3",
34+
"@types/node": "^25.0.7",
3535
"@types/react": "^19",
3636
"@types/react-dom": "^19",
3737
"@types/react-router-dom": "^5.3.3",

src/components/sider.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AppstoreOutlined,
33
FileTextOutlined,
44
PlusOutlined,
5+
SettingOutlined,
56
UserOutlined,
67
} from '@ant-design/icons';
78
import {
@@ -244,6 +245,23 @@ const SiderMenu = ({ selectedKeys }: SiderMenuProps) => {
244245
},
245246
],
246247
},
248+
...(user.admin
249+
? [
250+
{
251+
key: 'admin',
252+
icon: <SettingOutlined />,
253+
label: '管理员',
254+
children: [
255+
{
256+
key: 'admin-config',
257+
label: (
258+
<Link to={rootRouterPath.adminConfig}>动态配置</Link>
259+
),
260+
},
261+
],
262+
},
263+
]
264+
: []),
247265
]}
248266
/>
249267
</div>

src/pages/admin-config.tsx

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
3+
import {
4+
Button,
5+
Card,
6+
Form,
7+
Input,
8+
message,
9+
Modal,
10+
Popconfirm,
11+
Space,
12+
Spin,
13+
Table,
14+
Typography,
15+
} from 'antd';
16+
import { useCallback, useEffect, useRef, useState } from 'react';
17+
import { JSONEditor, type Content, type OnChange } from 'vanilla-jsoneditor';
18+
import { api } from '@/services/api';
19+
20+
const { Title } = Typography;
21+
22+
interface ConfigItem {
23+
key: string;
24+
value: string;
25+
}
26+
27+
// JSON Editor wrapper component
28+
const JsonEditorWrapper = ({
29+
value,
30+
onChange,
31+
}: {
32+
value: string;
33+
onChange: (value: string) => void;
34+
}) => {
35+
const containerRef = useRef<HTMLDivElement>(null);
36+
const editorRef = useRef<JSONEditor | null>(null);
37+
38+
useEffect(() => {
39+
if (containerRef.current && !editorRef.current) {
40+
const handleChange: OnChange = (
41+
content: Content,
42+
_previousContent: Content,
43+
{ contentErrors },
44+
) => {
45+
if (!contentErrors) {
46+
if ('json' in content && content.json !== undefined) {
47+
onChange(JSON.stringify(content.json, null, 2));
48+
} else if ('text' in content) {
49+
onChange(content.text);
50+
}
51+
}
52+
};
53+
54+
editorRef.current = new JSONEditor({
55+
target: containerRef.current,
56+
props: {
57+
content: { text: value },
58+
onChange: handleChange,
59+
mode: 'text',
60+
},
61+
});
62+
}
63+
64+
return () => {
65+
if (editorRef.current) {
66+
editorRef.current.destroy();
67+
editorRef.current = null;
68+
}
69+
};
70+
}, []);
71+
72+
useEffect(() => {
73+
if (editorRef.current) {
74+
editorRef.current.update({ text: value });
75+
}
76+
}, [value]);
77+
78+
return <div ref={containerRef} style={{ height: 300 }} />;
79+
};
80+
81+
export const Component = () => {
82+
const queryClient = useQueryClient();
83+
const [isModalOpen, setIsModalOpen] = useState(false);
84+
const [editingItem, setEditingItem] = useState<ConfigItem | null>(null);
85+
const [form] = Form.useForm();
86+
const [jsonValue, setJsonValue] = useState('');
87+
88+
const { data, isLoading, refetch } = useQuery({
89+
queryKey: ['adminConfig'],
90+
queryFn: () => api.getAdminConfig(),
91+
});
92+
93+
const configList: ConfigItem[] = data?.data
94+
? Object.entries(data.data).map(([key, value]) => ({ key, value }))
95+
: [];
96+
97+
const handleAdd = () => {
98+
setEditingItem(null);
99+
form.resetFields();
100+
setJsonValue('');
101+
setIsModalOpen(true);
102+
};
103+
104+
const handleEdit = (record: ConfigItem) => {
105+
setEditingItem(record);
106+
form.setFieldsValue({ key: record.key });
107+
// Pretty print JSON if possible
108+
try {
109+
setJsonValue(JSON.stringify(JSON.parse(record.value), null, 2));
110+
} catch {
111+
setJsonValue(record.value);
112+
}
113+
setIsModalOpen(true);
114+
};
115+
116+
const handleSave = async () => {
117+
try {
118+
const values = await form.validateFields();
119+
const key = values.key;
120+
121+
// Validate JSON and submit a compact string
122+
let parsedValue: unknown;
123+
try {
124+
parsedValue = JSON.parse(jsonValue);
125+
} catch {
126+
message.error('请输入有效的 JSON 格式');
127+
return;
128+
}
129+
130+
const compactValue = JSON.stringify(parsedValue);
131+
await api.setAdminConfig(key, compactValue);
132+
message.success('保存成功');
133+
setIsModalOpen(false);
134+
queryClient.invalidateQueries({ queryKey: ['adminConfig'] });
135+
} catch (error) {
136+
message.error((error as Error).message);
137+
}
138+
};
139+
140+
const handleDelete = useCallback(
141+
async (key: string) => {
142+
try {
143+
await api.deleteAdminConfig(key);
144+
message.success('已删除');
145+
refetch();
146+
} catch (error) {
147+
message.error((error as Error).message);
148+
}
149+
},
150+
[refetch],
151+
);
152+
153+
const columns = [
154+
{
155+
title: 'Key',
156+
dataIndex: 'key',
157+
key: 'key',
158+
width: 200,
159+
},
160+
{
161+
title: 'Value',
162+
dataIndex: 'value',
163+
key: 'value',
164+
render: (value: string) => {
165+
try {
166+
const parsed = JSON.parse(value);
167+
return (
168+
<pre className="m-0 max-h-24 overflow-auto text-xs bg-gray-100 p-2 rounded">
169+
{JSON.stringify(parsed, null, 2)}
170+
</pre>
171+
);
172+
} catch {
173+
return <span className="text-gray-600">{value}</span>;
174+
}
175+
},
176+
},
177+
{
178+
title: '操作',
179+
key: 'action',
180+
width: 150,
181+
render: (_: unknown, record: ConfigItem) => (
182+
<Space>
183+
<Button type="link" onClick={() => handleEdit(record)}>
184+
编辑
185+
</Button>
186+
<Popconfirm
187+
title="确定删除此配置?"
188+
onConfirm={() => handleDelete(record.key)}
189+
>
190+
<Button type="link" danger icon={<DeleteOutlined />} />
191+
</Popconfirm>
192+
</Space>
193+
),
194+
},
195+
];
196+
197+
return (
198+
<div className="p-6">
199+
<Card>
200+
<div className="flex justify-between items-center mb-4">
201+
<Title level={4} className="m-0!">
202+
动态配置管理
203+
</Title>
204+
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
205+
添加配置
206+
</Button>
207+
</div>
208+
209+
<Spin spinning={isLoading}>
210+
<Table
211+
dataSource={configList}
212+
columns={columns}
213+
rowKey="key"
214+
pagination={false}
215+
/>
216+
</Spin>
217+
</Card>
218+
219+
<Modal
220+
title={editingItem ? '编辑配置' : '添加配置'}
221+
open={isModalOpen}
222+
onCancel={() => setIsModalOpen(false)}
223+
footer={[
224+
<Button key="cancel" onClick={() => setIsModalOpen(false)}>
225+
取消
226+
</Button>,
227+
<Button
228+
key="save"
229+
type="primary"
230+
icon={<SaveOutlined />}
231+
onClick={handleSave}
232+
>
233+
保存
234+
</Button>,
235+
]}
236+
width={700}
237+
>
238+
<Form form={form} layout="vertical">
239+
<Form.Item
240+
name="key"
241+
label="Key"
242+
rules={[{ required: true, message: '请输入配置键名' }]}
243+
>
244+
<Input disabled={!!editingItem} placeholder="配置键名" />
245+
</Form.Item>
246+
<Form.Item label="Value (JSON)">
247+
<JsonEditorWrapper value={jsonValue} onChange={setJsonValue} />
248+
</Form.Item>
249+
</Form>
250+
</Modal>
251+
</div>
252+
);
253+
};

src/router.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const rootRouterPath = {
1616
welcome: '/welcome',
1717
register: '/register',
1818
auditLogs: '/audit-logs',
19+
adminConfig: '/admin-config',
1920
};
2021

2122
export const needAuthLoader = ({ request }: { request: Request }) => {
@@ -88,6 +89,11 @@ export const router = createHashRouter([
8889
loader: needAuthLoader,
8990
lazy: () => import('./pages/audit-logs'),
9091
},
92+
{
93+
path: 'admin-config',
94+
loader: needAuthLoader,
95+
lazy: () => import('./pages/admin-config'),
96+
},
9197
],
9298
},
9399
]);

src/services/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,13 @@ export const api = {
187187
'get',
188188
`/audit/logs?offset=${offset}&limit=${limit}&startDate=${startDate}`,
189189
),
190+
// admin
191+
getAdminConfig: () =>
192+
request<{ data?: Record<string, string> }>('get', `/admin/config`),
193+
setAdminConfig: (key: string, value: string) =>
194+
request<{ key: string; value: string }>('post', '/admin/config', {
195+
key,
196+
value,
197+
}),
198+
deleteAdminConfig: (key: string) => request('delete', `/admin/config/${key}`),
190199
};

src/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface User {
2424
checkQuota?: number;
2525
last7dAvg?: number;
2626
quota?: Quota;
27+
admin?: boolean;
2728
}
2829

2930
export interface Quota {

0 commit comments

Comments
 (0)