SettingsGeneral.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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, useMemo } from 'react';
  16. import {
  17. Banner,
  18. Button,
  19. Col,
  20. Form,
  21. Row,
  22. Spin,
  23. Modal,
  24. Select,
  25. InputGroup,
  26. Input,
  27. } from '@douyinfe/semi-ui';
  28. import {
  29. compareObjects,
  30. API,
  31. showError,
  32. showSuccess,
  33. showWarning,
  34. } from '../../../helpers';
  35. import { useTranslation } from 'react-i18next';
  36. export default function GeneralSettings(props) {
  37. const { t } = useTranslation();
  38. const [loading, setLoading] = useState(false);
  39. const [showQuotaWarning, setShowQuotaWarning] = useState(false);
  40. const [inputs, setInputs] = useState({
  41. TopUpLink: '',
  42. 'general_setting.docs_link': '',
  43. 'general_setting.quota_display_type': 'USD',
  44. 'general_setting.custom_currency_symbol': '¤',
  45. 'general_setting.custom_currency_exchange_rate': '',
  46. QuotaPerUnit: '',
  47. RetryTimes: '',
  48. USDExchangeRate: '',
  49. DisplayTokenStatEnabled: false,
  50. DefaultCollapseSidebar: false,
  51. DemoSiteEnabled: false,
  52. SelfUseModeEnabled: false,
  53. });
  54. const refForm = useRef();
  55. const [inputsRow, setInputsRow] = useState(inputs);
  56. function handleFieldChange(fieldName) {
  57. return (value) => {
  58. setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
  59. };
  60. }
  61. function onSubmit() {
  62. const updateArray = compareObjects(inputs, inputsRow);
  63. if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
  64. const requestQueue = updateArray.map((item) => {
  65. let value = '';
  66. if (typeof inputs[item.key] === 'boolean') {
  67. value = String(inputs[item.key]);
  68. } else {
  69. value = inputs[item.key];
  70. }
  71. return API.put('/api/option/', {
  72. key: item.key,
  73. value,
  74. });
  75. });
  76. setLoading(true);
  77. Promise.all(requestQueue)
  78. .then((res) => {
  79. if (requestQueue.length === 1) {
  80. if (res.includes(undefined)) return;
  81. } else if (requestQueue.length > 1) {
  82. if (res.includes(undefined))
  83. return showError(t('部分保存失败,请重试'));
  84. }
  85. showSuccess(t('保存成功'));
  86. props.refresh();
  87. })
  88. .catch(() => {
  89. showError(t('保存失败,请重试'));
  90. })
  91. .finally(() => {
  92. setLoading(false);
  93. });
  94. }
  95. // 计算展示在输入框中的“1 USD = X <currency>”中的 X
  96. const combinedRate = useMemo(() => {
  97. const type = inputs['general_setting.quota_display_type'];
  98. if (type === 'USD') return '1';
  99. if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
  100. if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
  101. if (type === 'CUSTOM')
  102. return String(
  103. inputs['general_setting.custom_currency_exchange_rate'] || '',
  104. );
  105. return '';
  106. }, [inputs]);
  107. const onCombinedRateChange = (val) => {
  108. const type = inputs['general_setting.quota_display_type'];
  109. if (type === 'CNY') {
  110. handleFieldChange('USDExchangeRate')(val);
  111. } else if (type === 'TOKENS') {
  112. handleFieldChange('QuotaPerUnit')(val);
  113. } else if (type === 'CUSTOM') {
  114. handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
  115. }
  116. };
  117. useEffect(() => {
  118. const currentInputs = {};
  119. for (let key in props.options) {
  120. if (Object.keys(inputs).includes(key)) {
  121. currentInputs[key] = props.options[key];
  122. }
  123. }
  124. // 若旧字段存在且新字段缺失,则做一次兜底映射
  125. if (
  126. currentInputs['general_setting.quota_display_type'] === undefined &&
  127. props.options?.DisplayInCurrencyEnabled !== undefined
  128. ) {
  129. currentInputs['general_setting.quota_display_type'] = props.options
  130. .DisplayInCurrencyEnabled
  131. ? 'USD'
  132. : 'TOKENS';
  133. }
  134. // 回填自定义货币相关字段(如果后端已存在)
  135. if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
  136. currentInputs['general_setting.custom_currency_symbol'] =
  137. props.options['general_setting.custom_currency_symbol'];
  138. }
  139. if (
  140. props.options['general_setting.custom_currency_exchange_rate'] !==
  141. undefined
  142. ) {
  143. currentInputs['general_setting.custom_currency_exchange_rate'] =
  144. props.options['general_setting.custom_currency_exchange_rate'];
  145. }
  146. setInputs(currentInputs);
  147. setInputsRow(structuredClone(currentInputs));
  148. refForm.current.setValues(currentInputs);
  149. }, [props.options]);
  150. return (
  151. <>
  152. <Spin spinning={loading}>
  153. <Form
  154. values={inputs}
  155. getFormApi={(formAPI) => (refForm.current = formAPI)}
  156. style={{ marginBottom: 15 }}
  157. >
  158. <Form.Section text={t('通用设置')}>
  159. <Row gutter={16}>
  160. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  161. <Form.Input
  162. field={'TopUpLink'}
  163. label={t('充值链接')}
  164. initValue={''}
  165. placeholder={t('例如发卡网站的购买链接')}
  166. onChange={handleFieldChange('TopUpLink')}
  167. showClear
  168. />
  169. </Col>
  170. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  171. <Form.Input
  172. field={'general_setting.docs_link'}
  173. label={t('文档地址')}
  174. initValue={''}
  175. placeholder={t('例如 https://docs.newapi.pro')}
  176. onChange={handleFieldChange('general_setting.docs_link')}
  177. showClear
  178. />
  179. </Col>
  180. {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
  181. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  182. <Form.Input
  183. field={'RetryTimes'}
  184. label={t('失败重试次数')}
  185. initValue={''}
  186. placeholder={t('失败重试次数')}
  187. onChange={handleFieldChange('RetryTimes')}
  188. showClear
  189. />
  190. </Col>
  191. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  192. <Form.Slot label={t('站点额度展示类型及汇率')}>
  193. <InputGroup style={{ width: '100%' }}>
  194. <Input
  195. prefix={'1 USD = '}
  196. style={{ width: '50%' }}
  197. value={combinedRate}
  198. onChange={onCombinedRateChange}
  199. disabled={
  200. inputs['general_setting.quota_display_type'] === 'USD'
  201. }
  202. />
  203. <Select
  204. style={{ width: '50%' }}
  205. value={inputs['general_setting.quota_display_type']}
  206. onChange={handleFieldChange(
  207. 'general_setting.quota_display_type',
  208. )}
  209. >
  210. <Select.Option value='USD'>USD ($)</Select.Option>
  211. <Select.Option value='CNY'>CNY (¥)</Select.Option>
  212. <Select.Option value='TOKENS'>Tokens</Select.Option>
  213. <Select.Option value='CUSTOM'>
  214. {t('自定义货币')}
  215. </Select.Option>
  216. </Select>
  217. </InputGroup>
  218. </Form.Slot>
  219. </Col>
  220. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  221. <Form.Input
  222. field={'general_setting.custom_currency_symbol'}
  223. label={t('自定义货币符号')}
  224. placeholder={t('例如 €, £, Rp, ₩, ₹...')}
  225. onChange={handleFieldChange(
  226. 'general_setting.custom_currency_symbol',
  227. )}
  228. showClear
  229. disabled={
  230. inputs['general_setting.quota_display_type'] !== 'CUSTOM'
  231. }
  232. />
  233. </Col>
  234. </Row>
  235. <Row gutter={16}>
  236. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  237. <Form.Switch
  238. field={'DisplayTokenStatEnabled'}
  239. label={t('额度查询接口返回令牌额度而非用户额度')}
  240. size='default'
  241. checkedText='|'
  242. uncheckedText='〇'
  243. onChange={handleFieldChange('DisplayTokenStatEnabled')}
  244. />
  245. </Col>
  246. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  247. <Form.Switch
  248. field={'DefaultCollapseSidebar'}
  249. label={t('默认折叠侧边栏')}
  250. size='default'
  251. checkedText='|'
  252. uncheckedText='〇'
  253. onChange={handleFieldChange('DefaultCollapseSidebar')}
  254. />
  255. </Col>
  256. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  257. <Form.Switch
  258. field={'DemoSiteEnabled'}
  259. label={t('演示站点模式')}
  260. size='default'
  261. checkedText='|'
  262. uncheckedText='〇'
  263. onChange={handleFieldChange('DemoSiteEnabled')}
  264. />
  265. </Col>
  266. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  267. <Form.Switch
  268. field={'SelfUseModeEnabled'}
  269. label={t('自用模式')}
  270. extraText={t('开启后不限制:必须设置模型倍率')}
  271. size='default'
  272. checkedText='|'
  273. uncheckedText='〇'
  274. onChange={handleFieldChange('SelfUseModeEnabled')}
  275. />
  276. </Col>
  277. </Row>
  278. <Row>
  279. <Button size='default' onClick={onSubmit}>
  280. {t('保存通用设置')}
  281. </Button>
  282. </Row>
  283. </Form.Section>
  284. </Form>
  285. </Spin>
  286. <Modal
  287. title={t('警告')}
  288. visible={showQuotaWarning}
  289. onOk={() => setShowQuotaWarning(false)}
  290. onCancel={() => setShowQuotaWarning(false)}
  291. closeOnEsc={true}
  292. width={500}
  293. >
  294. <Banner
  295. type='warning'
  296. description={t(
  297. '此设置用于系统内部计算,默认值500000是为了精确到6位小数点设计,不推荐修改。',
  298. )}
  299. bordered
  300. fullMode={false}
  301. closeIcon={null}
  302. />
  303. </Modal>
  304. </>
  305. );
  306. }