EditUserModal.jsx 14 KB

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