SettingsGeneral.jsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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. Input,
  25. Typography,
  26. } from '@douyinfe/semi-ui';
  27. import {
  28. compareObjects,
  29. API,
  30. showError,
  31. showSuccess,
  32. showWarning,
  33. } from '../../../helpers';
  34. import { useTranslation } from 'react-i18next';
  35. const { Text } = Typography;
  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. 'token_setting.max_user_tokens': 1000,
  54. });
  55. const refForm = useRef();
  56. const [inputsRow, setInputsRow] = useState(inputs);
  57. function handleFieldChange(fieldName) {
  58. return (value) => {
  59. setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
  60. };
  61. }
  62. function onSubmit() {
  63. const updateArray = compareObjects(inputs, inputsRow);
  64. if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
  65. const requestQueue = updateArray.map((item) => {
  66. let value = '';
  67. if (typeof inputs[item.key] === 'boolean') {
  68. value = String(inputs[item.key]);
  69. } else {
  70. value = inputs[item.key];
  71. }
  72. return API.put('/api/option/', {
  73. key: item.key,
  74. value,
  75. });
  76. });
  77. setLoading(true);
  78. Promise.all(requestQueue)
  79. .then((res) => {
  80. if (requestQueue.length === 1) {
  81. if (res.includes(undefined)) return;
  82. } else if (requestQueue.length > 1) {
  83. if (res.includes(undefined))
  84. return showError(t('部分保存失败,请重试'));
  85. }
  86. showSuccess(t('保存成功'));
  87. props.refresh();
  88. })
  89. .catch(() => {
  90. showError(t('保存失败,请重试'));
  91. })
  92. .finally(() => {
  93. setLoading(false);
  94. });
  95. }
  96. // 计算展示在输入框中的“1 USD = X <currency>”中的 X
  97. const combinedRate = useMemo(() => {
  98. const type = inputs['general_setting.quota_display_type'];
  99. if (type === 'USD') return '1';
  100. if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
  101. if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
  102. if (type === 'CUSTOM')
  103. return String(
  104. inputs['general_setting.custom_currency_exchange_rate'] || '',
  105. );
  106. return '';
  107. }, [inputs]);
  108. const onCombinedRateChange = (val) => {
  109. const type = inputs['general_setting.quota_display_type'];
  110. if (type === 'CNY') {
  111. handleFieldChange('USDExchangeRate')(val);
  112. } else if (type === 'TOKENS') {
  113. handleFieldChange('QuotaPerUnit')(val);
  114. } else if (type === 'CUSTOM') {
  115. handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
  116. }
  117. };
  118. const showTokensOption = useMemo(() => {
  119. const initialType = props.options?.['general_setting.quota_display_type'];
  120. const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
  121. const legacyTokensMode =
  122. initialType === undefined &&
  123. props.options?.DisplayInCurrencyEnabled !== undefined &&
  124. !props.options.DisplayInCurrencyEnabled;
  125. return (
  126. initialType === 'TOKENS' ||
  127. legacyTokensMode ||
  128. (!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
  129. );
  130. }, [props.options]);
  131. const quotaDisplayType = inputs['general_setting.quota_display_type'];
  132. const quotaDisplayTypeDesc = useMemo(() => {
  133. const descMap = {
  134. USD: t('站点所有额度将以美元 ($) 显示'),
  135. CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
  136. TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
  137. CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
  138. };
  139. return descMap[quotaDisplayType] || '';
  140. }, [quotaDisplayType, t]);
  141. const rateLabel = useMemo(() => {
  142. if (quotaDisplayType === 'CNY') return t('汇率');
  143. if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
  144. if (quotaDisplayType === 'CUSTOM') return t('汇率');
  145. return '';
  146. }, [quotaDisplayType, t]);
  147. const rateSuffix = useMemo(() => {
  148. if (quotaDisplayType === 'CNY') return 'CNY (¥)';
  149. if (quotaDisplayType === 'TOKENS') return 'Tokens';
  150. if (quotaDisplayType === 'CUSTOM')
  151. return inputs['general_setting.custom_currency_symbol'] || '¤';
  152. return '';
  153. }, [quotaDisplayType, inputs]);
  154. const rateExtraText = useMemo(() => {
  155. if (quotaDisplayType === 'CNY')
  156. return t(
  157. '系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
  158. );
  159. if (quotaDisplayType === 'TOKENS')
  160. return t(
  161. '系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
  162. );
  163. if (quotaDisplayType === 'CUSTOM')
  164. return t(
  165. '系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
  166. );
  167. return '';
  168. }, [quotaDisplayType, t]);
  169. const previewText = useMemo(() => {
  170. if (quotaDisplayType === 'USD') return '$1.00';
  171. const rate = parseFloat(combinedRate);
  172. if (!rate || isNaN(rate)) return t('请输入汇率');
  173. if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
  174. if (quotaDisplayType === 'TOKENS')
  175. return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
  176. if (quotaDisplayType === 'CUSTOM') {
  177. const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
  178. return `$1.00 → ${symbol}${rate.toFixed(2)}`;
  179. }
  180. return '';
  181. }, [quotaDisplayType, combinedRate, inputs, t]);
  182. useEffect(() => {
  183. const currentInputs = {};
  184. for (let key in props.options) {
  185. if (Object.keys(inputs).includes(key)) {
  186. currentInputs[key] = props.options[key];
  187. }
  188. }
  189. // 若旧字段存在且新字段缺失,则做一次兜底映射
  190. if (
  191. currentInputs['general_setting.quota_display_type'] === undefined &&
  192. props.options?.DisplayInCurrencyEnabled !== undefined
  193. ) {
  194. currentInputs['general_setting.quota_display_type'] = props.options
  195. .DisplayInCurrencyEnabled
  196. ? 'USD'
  197. : 'TOKENS';
  198. }
  199. // 回填自定义货币相关字段(如果后端已存在)
  200. if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
  201. currentInputs['general_setting.custom_currency_symbol'] =
  202. props.options['general_setting.custom_currency_symbol'];
  203. }
  204. if (
  205. props.options['general_setting.custom_currency_exchange_rate'] !==
  206. undefined
  207. ) {
  208. currentInputs['general_setting.custom_currency_exchange_rate'] =
  209. props.options['general_setting.custom_currency_exchange_rate'];
  210. }
  211. setInputs(currentInputs);
  212. setInputsRow(structuredClone(currentInputs));
  213. refForm.current.setValues(currentInputs);
  214. }, [props.options]);
  215. return (
  216. <>
  217. <Spin spinning={loading}>
  218. <Form
  219. values={inputs}
  220. getFormApi={(formAPI) => (refForm.current = formAPI)}
  221. style={{ marginBottom: 15 }}
  222. >
  223. <Form.Section text={t('通用设置')}>
  224. <Row gutter={16}>
  225. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  226. <Form.Input
  227. field={'TopUpLink'}
  228. label={t('充值链接')}
  229. initValue={''}
  230. placeholder={t('例如发卡网站的购买链接')}
  231. onChange={handleFieldChange('TopUpLink')}
  232. showClear
  233. />
  234. </Col>
  235. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  236. <Form.Input
  237. field={'general_setting.docs_link'}
  238. label={t('文档地址')}
  239. initValue={''}
  240. placeholder={t('例如 https://docs.newapi.pro')}
  241. onChange={handleFieldChange('general_setting.docs_link')}
  242. showClear
  243. />
  244. </Col>
  245. {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
  246. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  247. <Form.Input
  248. field={'RetryTimes'}
  249. label={t('失败重试次数')}
  250. initValue={''}
  251. placeholder={t('失败重试次数')}
  252. onChange={handleFieldChange('RetryTimes')}
  253. showClear
  254. />
  255. </Col>
  256. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  257. <Form.Select
  258. field='general_setting.quota_display_type'
  259. label={t('额度展示类型')}
  260. extraText={quotaDisplayTypeDesc}
  261. onChange={handleFieldChange(
  262. 'general_setting.quota_display_type',
  263. )}
  264. >
  265. <Form.Select.Option value='USD'>
  266. USD ($)
  267. </Form.Select.Option>
  268. <Form.Select.Option value='CNY'>
  269. CNY (¥)
  270. </Form.Select.Option>
  271. {showTokensOption && (
  272. <Form.Select.Option value='TOKENS'>
  273. Tokens
  274. </Form.Select.Option>
  275. )}
  276. <Form.Select.Option value='CUSTOM'>
  277. {t('自定义货币')}
  278. </Form.Select.Option>
  279. </Form.Select>
  280. </Col>
  281. {quotaDisplayType !== 'USD' && (
  282. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  283. <Form.Slot label={rateLabel}>
  284. <Input
  285. prefix='1 USD = '
  286. suffix={rateSuffix}
  287. value={combinedRate}
  288. onChange={onCombinedRateChange}
  289. />
  290. <Text
  291. type='tertiary'
  292. size='small'
  293. style={{ marginTop: 4, display: 'block' }}
  294. >
  295. {rateExtraText}
  296. </Text>
  297. </Form.Slot>
  298. </Col>
  299. )}
  300. <Col
  301. xs={24}
  302. sm={12}
  303. md={8}
  304. lg={8}
  305. xl={8}
  306. style={
  307. quotaDisplayType !== 'CUSTOM'
  308. ? { display: 'none' }
  309. : undefined
  310. }
  311. >
  312. <Form.Input
  313. field='general_setting.custom_currency_symbol'
  314. label={t('自定义货币符号')}
  315. extraText={t(
  316. '自定义货币符号将显示在所有额度数值前,例如 €1.50',
  317. )}
  318. placeholder={t('例如 €, £, Rp, ₩, ₹...')}
  319. onChange={handleFieldChange(
  320. 'general_setting.custom_currency_symbol',
  321. )}
  322. showClear
  323. />
  324. </Col>
  325. <Col span={24}>
  326. <Text type='tertiary' size='small'>
  327. {t('预览效果')}:{previewText}
  328. </Text>
  329. </Col>
  330. </Row>
  331. <Row gutter={16}>
  332. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  333. <Form.Switch
  334. field={'DisplayTokenStatEnabled'}
  335. label={t('额度查询接口返回令牌额度而非用户额度')}
  336. size='default'
  337. checkedText='|'
  338. uncheckedText='〇'
  339. onChange={handleFieldChange('DisplayTokenStatEnabled')}
  340. />
  341. </Col>
  342. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  343. <Form.Switch
  344. field={'DefaultCollapseSidebar'}
  345. label={t('默认折叠侧边栏')}
  346. size='default'
  347. checkedText='|'
  348. uncheckedText='〇'
  349. onChange={handleFieldChange('DefaultCollapseSidebar')}
  350. />
  351. </Col>
  352. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  353. <Form.Switch
  354. field={'DemoSiteEnabled'}
  355. label={t('演示站点模式')}
  356. size='default'
  357. checkedText='|'
  358. uncheckedText='〇'
  359. onChange={handleFieldChange('DemoSiteEnabled')}
  360. />
  361. </Col>
  362. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  363. <Form.Switch
  364. field={'SelfUseModeEnabled'}
  365. label={t('自用模式')}
  366. extraText={t('开启后不限制:必须设置模型倍率')}
  367. size='default'
  368. checkedText='|'
  369. uncheckedText='〇'
  370. onChange={handleFieldChange('SelfUseModeEnabled')}
  371. />
  372. </Col>
  373. </Row>
  374. <Row gutter={16}>
  375. <Col xs={24} sm={12} md={8} lg={8} xl={8}>
  376. <Form.InputNumber
  377. label={t('用户最大令牌数量')}
  378. field={'token_setting.max_user_tokens'}
  379. step={1}
  380. min={1}
  381. extraText={t('每个用户最多可创建的令牌数量,默认 1000,设置过大可能会影响性能')}
  382. placeholder={'1000'}
  383. onChange={handleFieldChange('token_setting.max_user_tokens')}
  384. />
  385. </Col>
  386. </Row>
  387. <Row>
  388. <Button size='default' onClick={onSubmit}>
  389. {t('保存通用设置')}
  390. </Button>
  391. </Row>
  392. </Form.Section>
  393. </Form>
  394. </Spin>
  395. <Modal
  396. title={t('警告')}
  397. visible={showQuotaWarning}
  398. onOk={() => setShowQuotaWarning(false)}
  399. onCancel={() => setShowQuotaWarning(false)}
  400. closeOnEsc={true}
  401. width={500}
  402. >
  403. <Banner
  404. type='warning'
  405. description={t(
  406. '此设置用于系统内部计算,默认值500000是为了精确到6位小数点设计,不推荐修改。',
  407. )}
  408. bordered
  409. fullMode={false}
  410. closeIcon={null}
  411. />
  412. </Modal>
  413. </>
  414. );
  415. }