EditUserModal.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  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. getCurrencyConfig,
  24. } from '../../../../helpers';
  25. import {
  26. quotaToDisplayAmount,
  27. displayAmountToQuota,
  28. } from '../../../../helpers/quota';
  29. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  30. import {
  31. Button,
  32. Modal,
  33. SideSheet,
  34. Space,
  35. Spin,
  36. Typography,
  37. Card,
  38. Tag,
  39. Form,
  40. Avatar,
  41. Row,
  42. Col,
  43. Input,
  44. InputNumber,
  45. } from '@douyinfe/semi-ui';
  46. import {
  47. IconUser,
  48. IconSave,
  49. IconClose,
  50. IconLink,
  51. IconUserGroup,
  52. IconPlus,
  53. } from '@douyinfe/semi-icons';
  54. const { Text, Title } = Typography;
  55. const EditUserModal = (props) => {
  56. const { t } = useTranslation();
  57. const userId = props.editingUser.id;
  58. const [loading, setLoading] = useState(true);
  59. const [addQuotaModalOpen, setIsModalOpen] = useState(false);
  60. const [addQuotaLocal, setAddQuotaLocal] = useState('');
  61. const [addAmountLocal, setAddAmountLocal] = useState('');
  62. const isMobile = useIsMobile();
  63. const [groupOptions, setGroupOptions] = useState([]);
  64. const formApiRef = useRef(null);
  65. const isEdit = Boolean(userId);
  66. const getInitValues = () => ({
  67. username: '',
  68. display_name: '',
  69. password: '',
  70. github_id: '',
  71. oidc_id: '',
  72. discord_id: '',
  73. wechat_id: '',
  74. telegram_id: '',
  75. email: '',
  76. quota: 0,
  77. group: 'default',
  78. remark: '',
  79. });
  80. const fetchGroups = async () => {
  81. try {
  82. let res = await API.get(`/api/group/`);
  83. setGroupOptions(res.data.data.map((g) => ({ label: g, value: g })));
  84. } catch (e) {
  85. showError(e.message);
  86. }
  87. };
  88. const handleCancel = () => props.handleClose();
  89. const loadUser = async () => {
  90. setLoading(true);
  91. const url = userId ? `/api/user/${userId}` : `/api/user/self`;
  92. const res = await API.get(url);
  93. const { success, message, data } = res.data;
  94. if (success) {
  95. data.password = '';
  96. formApiRef.current?.setValues({ ...getInitValues(), ...data });
  97. } else {
  98. showError(message);
  99. }
  100. setLoading(false);
  101. };
  102. useEffect(() => {
  103. loadUser();
  104. if (userId) fetchGroups();
  105. }, [props.editingUser.id]);
  106. /* ----------------------- submit ----------------------- */
  107. const submit = async (values) => {
  108. setLoading(true);
  109. let payload = { ...values };
  110. if (typeof payload.quota === 'string')
  111. payload.quota = parseInt(payload.quota) || 0;
  112. if (userId) {
  113. payload.id = parseInt(userId);
  114. }
  115. const url = userId ? `/api/user/` : `/api/user/self`;
  116. const res = await API.put(url, payload);
  117. const { success, message } = res.data;
  118. if (success) {
  119. showSuccess(t('用户信息更新成功!'));
  120. props.refresh();
  121. props.handleClose();
  122. } else {
  123. showError(message);
  124. }
  125. setLoading(false);
  126. };
  127. /* --------------------- quota helper -------------------- */
  128. const addLocalQuota = () => {
  129. const current = parseInt(formApiRef.current?.getValue('quota') || 0);
  130. const delta = parseInt(addQuotaLocal) || 0;
  131. formApiRef.current?.setValue('quota', current + delta);
  132. };
  133. /* --------------------------- UI --------------------------- */
  134. return (
  135. <>
  136. <SideSheet
  137. placement='right'
  138. title={
  139. <Space>
  140. <Tag color='blue' shape='circle'>
  141. {t(isEdit ? '编辑' : '新建')}
  142. </Tag>
  143. <Title heading={4} className='m-0'>
  144. {isEdit ? t('编辑用户') : t('创建用户')}
  145. </Title>
  146. </Space>
  147. }
  148. bodyStyle={{ padding: 0 }}
  149. visible={props.visible}
  150. width={isMobile ? '100%' : 600}
  151. footer={
  152. <div className='flex justify-end bg-white'>
  153. <Space>
  154. <Button
  155. theme='solid'
  156. onClick={() => formApiRef.current?.submitForm()}
  157. icon={<IconSave />}
  158. loading={loading}
  159. >
  160. {t('提交')}
  161. </Button>
  162. <Button
  163. theme='light'
  164. type='primary'
  165. onClick={handleCancel}
  166. icon={<IconClose />}
  167. >
  168. {t('取消')}
  169. </Button>
  170. </Space>
  171. </div>
  172. }
  173. closeIcon={null}
  174. onCancel={handleCancel}
  175. >
  176. <Spin spinning={loading}>
  177. <Form
  178. initValues={getInitValues()}
  179. getFormApi={(api) => (formApiRef.current = api)}
  180. onSubmit={submit}
  181. >
  182. {({ values }) => (
  183. <div className='p-2'>
  184. {/* 基本信息 */}
  185. <Card className='!rounded-2xl shadow-sm border-0'>
  186. <div className='flex items-center mb-2'>
  187. <Avatar
  188. size='small'
  189. color='blue'
  190. className='mr-2 shadow-md'
  191. >
  192. <IconUser size={16} />
  193. </Avatar>
  194. <div>
  195. <Text className='text-lg font-medium'>
  196. {t('基本信息')}
  197. </Text>
  198. <div className='text-xs text-gray-600'>
  199. {t('用户的基本账户信息')}
  200. </div>
  201. </div>
  202. </div>
  203. <Row gutter={12}>
  204. <Col span={24}>
  205. <Form.Input
  206. field='username'
  207. label={t('用户名')}
  208. placeholder={t('请输入新的用户名')}
  209. rules={[{ required: true, message: t('请输入用户名') }]}
  210. showClear
  211. />
  212. </Col>
  213. <Col span={24}>
  214. <Form.Input
  215. field='password'
  216. label={t('密码')}
  217. placeholder={t('请输入新的密码,最短 8 位')}
  218. mode='password'
  219. showClear
  220. />
  221. </Col>
  222. <Col span={24}>
  223. <Form.Input
  224. field='display_name'
  225. label={t('显示名称')}
  226. placeholder={t('请输入新的显示名称')}
  227. showClear
  228. />
  229. </Col>
  230. <Col span={24}>
  231. <Form.Input
  232. field='remark'
  233. label={t('备注')}
  234. placeholder={t('请输入备注(仅管理员可见)')}
  235. showClear
  236. />
  237. </Col>
  238. </Row>
  239. </Card>
  240. {/* 权限设置 */}
  241. {userId && (
  242. <Card className='!rounded-2xl shadow-sm border-0'>
  243. <div className='flex items-center mb-2'>
  244. <Avatar
  245. size='small'
  246. color='green'
  247. className='mr-2 shadow-md'
  248. >
  249. <IconUserGroup size={16} />
  250. </Avatar>
  251. <div>
  252. <Text className='text-lg font-medium'>
  253. {t('权限设置')}
  254. </Text>
  255. <div className='text-xs text-gray-600'>
  256. {t('用户分组和额度管理')}
  257. </div>
  258. </div>
  259. </div>
  260. <Row gutter={12}>
  261. <Col span={24}>
  262. <Form.Select
  263. field='group'
  264. label={t('分组')}
  265. placeholder={t('请选择分组')}
  266. optionList={groupOptions}
  267. allowAdditions
  268. search
  269. rules={[{ required: true, message: t('请选择分组') }]}
  270. />
  271. </Col>
  272. <Col span={10}>
  273. <Form.InputNumber
  274. field='quota'
  275. label={t('剩余额度')}
  276. placeholder={t('请输入新的剩余额度')}
  277. step={500000}
  278. extraText={renderQuotaWithPrompt(values.quota || 0)}
  279. rules={[{ required: true, message: t('请输入额度') }]}
  280. style={{ width: '100%' }}
  281. />
  282. </Col>
  283. <Col span={14}>
  284. <Form.Slot label={t('添加额度')}>
  285. <Button
  286. icon={<IconPlus />}
  287. onClick={() => setIsModalOpen(true)}
  288. />
  289. </Form.Slot>
  290. </Col>
  291. </Row>
  292. </Card>
  293. )}
  294. {/* 绑定信息 */}
  295. <Card className='!rounded-2xl shadow-sm border-0'>
  296. <div className='flex items-center mb-2'>
  297. <Avatar
  298. size='small'
  299. color='purple'
  300. className='mr-2 shadow-md'
  301. >
  302. <IconLink size={16} />
  303. </Avatar>
  304. <div>
  305. <Text className='text-lg font-medium'>
  306. {t('绑定信息')}
  307. </Text>
  308. <div className='text-xs text-gray-600'>
  309. {t('第三方账户绑定状态(只读)')}
  310. </div>
  311. </div>
  312. </div>
  313. <Row gutter={12}>
  314. {[
  315. 'github_id',
  316. 'discord_id',
  317. 'oidc_id',
  318. 'wechat_id',
  319. 'email',
  320. 'telegram_id',
  321. ].map((field) => (
  322. <Col span={24} key={field}>
  323. <Form.Input
  324. field={field}
  325. label={t(
  326. `已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`,
  327. )}
  328. readonly
  329. placeholder={t(
  330. '此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
  331. )}
  332. />
  333. </Col>
  334. ))}
  335. </Row>
  336. </Card>
  337. </div>
  338. )}
  339. </Form>
  340. </Spin>
  341. </SideSheet>
  342. {/* 添加额度模态框 */}
  343. <Modal
  344. centered
  345. visible={addQuotaModalOpen}
  346. onOk={() => {
  347. addLocalQuota();
  348. setIsModalOpen(false);
  349. setAddQuotaLocal('');
  350. setAddAmountLocal('');
  351. }}
  352. onCancel={() => {
  353. setIsModalOpen(false);
  354. }}
  355. closable={null}
  356. title={
  357. <div className='flex items-center'>
  358. <IconPlus className='mr-2' />
  359. {t('添加额度')}
  360. </div>
  361. }
  362. >
  363. <div className='mb-4'>
  364. {(() => {
  365. const current = formApiRef.current?.getValue('quota') || 0;
  366. return (
  367. <Text type='secondary' className='block mb-2'>
  368. {`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
  369. </Text>
  370. );
  371. })()}
  372. </div>
  373. {getCurrencyConfig().type !== 'TOKENS' && (
  374. <div className='mb-3'>
  375. <div className='mb-1'>
  376. <Text size='small'>{t('金额')}</Text>
  377. <Text size='small' type='tertiary'> ({t('仅用于换算,实际保存的是额度')})</Text>
  378. </div>
  379. <InputNumber
  380. prefix={getCurrencyConfig().symbol}
  381. placeholder={t('输入金额')}
  382. value={addAmountLocal}
  383. precision={2}
  384. onChange={(val) => {
  385. setAddAmountLocal(val);
  386. setAddQuotaLocal(
  387. val != null && val !== '' ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) : '',
  388. );
  389. }}
  390. style={{ width: '100%' }}
  391. showClear
  392. />
  393. </div>
  394. )}
  395. <div>
  396. <div className='mb-1'>
  397. <Text size='small'>{t('额度')}</Text>
  398. </div>
  399. <InputNumber
  400. placeholder={t('输入额度')}
  401. value={addQuotaLocal}
  402. onChange={(val) => {
  403. setAddQuotaLocal(val);
  404. setAddAmountLocal(
  405. val != null && val !== ''
  406. ? Number((quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)).toFixed(2))
  407. : '',
  408. );
  409. }}
  410. style={{ width: '100%' }}
  411. showClear
  412. step={500000}
  413. />
  414. </div>
  415. </Modal>
  416. </>
  417. );
  418. };
  419. export default EditUserModal;