Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

486 строки
15 KiB

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