EditUserModal.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useState, useRef } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import {
  18. API,
  19. showError,
  20. showSuccess,
  21. renderQuota,
  22. renderQuotaWithPrompt,
  23. } from '../../../../helpers';
  24. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  25. import {
  26. Button,
  27. Modal,
  28. SideSheet,
  29. Space,
  30. Spin,
  31. Typography,
  32. Card,
  33. Tag,
  34. Form,
  35. Avatar,
  36. Row,
  37. Col,
  38. Input,
  39. InputNumber,
  40. } from '@douyinfe/semi-ui';
  41. import {
  42. IconUser,
  43. IconSave,
  44. IconClose,
  45. IconLink,
  46. IconUserGroup,
  47. IconPlus,
  48. } from '@douyinfe/semi-icons';
  49. const { Text, Title } = Typography;
  50. const EditUserModal = (props) => {
  51. const { t } = useTranslation();
  52. const userId = props.editingUser.id;
  53. const [loading, setLoading] = useState(true);
  54. const [addQuotaModalOpen, setIsModalOpen] = useState(false);
  55. const [addQuotaLocal, setAddQuotaLocal] = useState('');
  56. const isMobile = useIsMobile();
  57. const [groupOptions, setGroupOptions] = useState([]);
  58. const formApiRef = useRef(null);
  59. const isEdit = Boolean(userId);
  60. const getInitValues = () => ({
  61. username: '',
  62. display_name: '',
  63. password: '',
  64. github_id: '',
  65. oidc_id: '',
  66. discord_id: '',
  67. wechat_id: '',
  68. telegram_id: '',
  69. email: '',
  70. quota: 0,
  71. group: 'default',
  72. remark: '',
  73. });
  74. const fetchGroups = async () => {
  75. try {
  76. let res = await API.get(`/api/group/`);
  77. setGroupOptions(res.data.data.map((g) => ({ label: g, value: g })));
  78. } catch (e) {
  79. showError(e.message);
  80. }
  81. };
  82. const handleCancel = () => props.handleClose();
  83. const loadUser = async () => {
  84. setLoading(true);
  85. const url = userId ? `/api/user/${userId}` : `/api/user/self`;
  86. const res = await API.get(url);
  87. const { success, message, data } = res.data;
  88. if (success) {
  89. data.password = '';
  90. formApiRef.current?.setValues({ ...getInitValues(), ...data });
  91. } else {
  92. showError(message);
  93. }
  94. setLoading(false);
  95. };
  96. useEffect(() => {
  97. loadUser();
  98. if (userId) fetchGroups();
  99. }, [props.editingUser.id]);
  100. /* ----------------------- submit ----------------------- */
  101. const submit = async (values) => {
  102. setLoading(true);
  103. let payload = { ...values };
  104. if (typeof payload.quota === 'string')
  105. payload.quota = parseInt(payload.quota) || 0;
  106. if (userId) {
  107. payload.id = parseInt(userId);
  108. }
  109. const url = userId ? `/api/user/` : `/api/user/self`;
  110. const res = await API.put(url, payload);
  111. const { success, message } = res.data;
  112. if (success) {
  113. showSuccess(t('用户信息更新成功!'));
  114. props.refresh();
  115. props.handleClose();
  116. } else {
  117. showError(message);
  118. }
  119. setLoading(false);
  120. };
  121. /* --------------------- quota helper -------------------- */
  122. const addLocalQuota = () => {
  123. const current = parseInt(formApiRef.current?.getValue('quota') || 0);
  124. const delta = parseInt(addQuotaLocal) || 0;
  125. formApiRef.current?.setValue('quota', current + delta);
  126. };
  127. /* --------------------------- UI --------------------------- */
  128. return (
  129. <>
  130. <SideSheet
  131. placement='right'
  132. title={
  133. <Space>
  134. <Tag color='blue' shape='circle'>
  135. {t(isEdit ? '编辑' : '新建')}
  136. </Tag>
  137. <Title heading={4} className='m-0'>
  138. {isEdit ? t('编辑用户') : t('创建用户')}
  139. </Title>
  140. </Space>
  141. }
  142. bodyStyle={{ padding: 0 }}
  143. visible={props.visible}
  144. width={isMobile ? '100%' : 600}
  145. footer={
  146. <div className='flex justify-end bg-white'>
  147. <Space>
  148. <Button
  149. theme='solid'
  150. onClick={() => formApiRef.current?.submitForm()}
  151. icon={<IconSave />}
  152. loading={loading}
  153. >
  154. {t('提交')}
  155. </Button>
  156. <Button
  157. theme='light'
  158. type='primary'
  159. onClick={handleCancel}
  160. icon={<IconClose />}
  161. >
  162. {t('取消')}
  163. </Button>
  164. </Space>
  165. </div>
  166. }
  167. closeIcon={null}
  168. onCancel={handleCancel}
  169. >
  170. <Spin spinning={loading}>
  171. <Form
  172. initValues={getInitValues()}
  173. getFormApi={(api) => (formApiRef.current = api)}
  174. onSubmit={submit}
  175. >
  176. {({ values }) => (
  177. <div className='p-2'>
  178. {/* 基本信息 */}
  179. <Card className='!rounded-2xl shadow-sm border-0'>
  180. <div className='flex items-center mb-2'>
  181. <Avatar
  182. size='small'
  183. color='blue'
  184. className='mr-2 shadow-md'
  185. >
  186. <IconUser size={16} />
  187. </Avatar>
  188. <div>
  189. <Text className='text-lg font-medium'>
  190. {t('基本信息')}
  191. </Text>
  192. <div className='text-xs text-gray-600'>
  193. {t('用户的基本账户信息')}
  194. </div>
  195. </div>
  196. </div>
  197. <Row gutter={12}>
  198. <Col span={24}>
  199. <Form.Input
  200. field='username'
  201. label={t('用户名')}
  202. placeholder={t('请输入新的用户名')}
  203. rules={[{ required: true, message: t('请输入用户名') }]}
  204. showClear
  205. />
  206. </Col>
  207. <Col span={24}>
  208. <Form.Input
  209. field='password'
  210. label={t('密码')}
  211. placeholder={t('请输入新的密码,最短 8 位')}
  212. mode='password'
  213. showClear
  214. />
  215. </Col>
  216. <Col span={24}>
  217. <Form.Input
  218. field='display_name'
  219. label={t('显示名称')}
  220. placeholder={t('请输入新的显示名称')}
  221. showClear
  222. />
  223. </Col>
  224. <Col span={24}>
  225. <Form.Input
  226. field='remark'
  227. label={t('备注')}
  228. placeholder={t('请输入备注(仅管理员可见)')}
  229. showClear
  230. />
  231. </Col>
  232. </Row>
  233. </Card>
  234. {/* 权限设置 */}
  235. {userId && (
  236. <Card className='!rounded-2xl shadow-sm border-0'>
  237. <div className='flex items-center mb-2'>
  238. <Avatar
  239. size='small'
  240. color='green'
  241. className='mr-2 shadow-md'
  242. >
  243. <IconUserGroup size={16} />
  244. </Avatar>
  245. <div>
  246. <Text className='text-lg font-medium'>
  247. {t('权限设置')}
  248. </Text>
  249. <div className='text-xs text-gray-600'>
  250. {t('用户分组和额度管理')}
  251. </div>
  252. </div>
  253. </div>
  254. <Row gutter={12}>
  255. <Col span={24}>
  256. <Form.Select
  257. field='group'
  258. label={t('分组')}
  259. placeholder={t('请选择分组')}
  260. optionList={groupOptions}
  261. allowAdditions
  262. search
  263. rules={[{ required: true, message: t('请选择分组') }]}
  264. />
  265. </Col>
  266. <Col span={10}>
  267. <Form.InputNumber
  268. field='quota'
  269. label={t('剩余额度')}
  270. placeholder={t('请输入新的剩余额度')}
  271. step={500000}
  272. extraText={renderQuotaWithPrompt(values.quota || 0)}
  273. rules={[{ required: true, message: t('请输入额度') }]}
  274. style={{ width: '100%' }}
  275. />
  276. </Col>
  277. <Col span={14}>
  278. <Form.Slot label={t('添加额度')}>
  279. <Button
  280. icon={<IconPlus />}
  281. onClick={() => setIsModalOpen(true)}
  282. />
  283. </Form.Slot>
  284. </Col>
  285. </Row>
  286. </Card>
  287. )}
  288. {/* 绑定信息 */}
  289. <Card className='!rounded-2xl shadow-sm border-0'>
  290. <div className='flex items-center mb-2'>
  291. <Avatar
  292. size='small'
  293. color='purple'
  294. className='mr-2 shadow-md'
  295. >
  296. <IconLink size={16} />
  297. </Avatar>
  298. <div>
  299. <Text className='text-lg font-medium'>
  300. {t('绑定信息')}
  301. </Text>
  302. <div className='text-xs text-gray-600'>
  303. {t('第三方账户绑定状态(只读)')}
  304. </div>
  305. </div>
  306. </div>
  307. <Row gutter={12}>
  308. {[
  309. 'github_id',
  310. 'discord_id',
  311. 'oidc_id',
  312. 'wechat_id',
  313. 'email',
  314. 'telegram_id',
  315. ].map((field) => (
  316. <Col span={24} key={field}>
  317. <Form.Input
  318. field={field}
  319. label={t(
  320. `已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`,
  321. )}
  322. readonly
  323. placeholder={t(
  324. '此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
  325. )}
  326. />
  327. </Col>
  328. ))}
  329. </Row>
  330. </Card>
  331. </div>
  332. )}
  333. </Form>
  334. </Spin>
  335. </SideSheet>
  336. {/* 添加额度模态框 */}
  337. <Modal
  338. centered
  339. visible={addQuotaModalOpen}
  340. onOk={() => {
  341. addLocalQuota();
  342. setIsModalOpen(false);
  343. }}
  344. onCancel={() => setIsModalOpen(false)}
  345. closable={null}
  346. title={
  347. <div className='flex items-center'>
  348. <IconPlus className='mr-2' />
  349. {t('添加额度')}
  350. </div>
  351. }
  352. >
  353. <div className='mb-4'>
  354. {(() => {
  355. const current = formApiRef.current?.getValue('quota') || 0;
  356. return (
  357. <Text type='secondary' className='block mb-2'>
  358. {`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
  359. </Text>
  360. );
  361. })()}
  362. </div>
  363. <InputNumber
  364. placeholder={t('需要添加的额度(支持负数)')}
  365. value={addQuotaLocal}
  366. onChange={setAddQuotaLocal}
  367. style={{ width: '100%' }}
  368. showClear
  369. step={500000}
  370. />
  371. </Modal>
  372. </>
  373. );
  374. };
  375. export default EditUserModal;